diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 5f1f206..79acf58 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Appearance } from "react-native"; import { StatusBar } from "expo-status-bar"; import "react-native-gesture-handler"; import { NativeBaseProvider } from "./src/providers"; @@ -8,6 +9,7 @@ import { useAlgaeRecords, AlgaeRecordsContext, } from "./src/hooks/useAlgaeRecords"; +import { getAppSettings } from "./AppSettings"; export function App() { const [algaeRecords] = useAlgaeRecords(); @@ -24,6 +26,8 @@ export function App() { return null; } + Appearance.setColorScheme(getAppSettings().colorMode); + return ( diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 53c8484..74f0c11 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -30,6 +30,7 @@ } }, "android": { + "softwareKeyboardLayoutMode": "resize", "package": "com.livingsnowproject.livingsnowproject", "versionCode": 1, "permissions": [ diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js index 358dfc1..de9a606 100644 --- a/apps/mobile/babel.config.js +++ b/apps/mobile/babel.config.js @@ -2,6 +2,5 @@ module.exports = function (api) { api.cache(true); return { presets: ["babel-preset-expo", "@babel/preset-typescript"], - plugins: ["react-native-reanimated/plugin"], }; }; diff --git a/apps/mobile/jesttest.setup.js b/apps/mobile/jesttest.setup.js index 194d319..05aa1a4 100644 --- a/apps/mobile/jesttest.setup.js +++ b/apps/mobile/jesttest.setup.js @@ -7,6 +7,18 @@ jest.mock("@react-native-community/netinfo", () => mockRNCNetInfo); jest.mock("@react-native-async-storage/async-storage", () => mockAsyncStorage); +jest.mock("react-native-avoid-softinput", () => { + const mock = require("react-native-avoid-softinput/jest/mock"); + + /** + * If needed, override mock like so: + * + * return Object.assign(mock, { useSoftInputState: jest.fn(() => ({ isSoftInputShown: true, softInputHeight: 300 })) }); + */ + + return mock; +}); + // useNativeDriver for animations doesn't exist in test environment // https://github.com/ptomasroos/react-native-scrollable-tab-view/issues/642 jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper"); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3caea52..f9acd67 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -47,11 +47,11 @@ "react-dom": "18.2.0", "react-is": "^17.0.1", "react-native": "0.74.1", + "react-native-avoid-softinput": "^5.0.0", "react-native-calendars": "1.1293.0", "react-native-gesture-handler": "~2.16.1", "react-native-get-random-values": "~1.11.0", "react-native-picker-select": "^8.0.4", - "react-native-reanimated": "~3.10.1", "react-native-safe-area-context": "4.10.1", "react-native-screens": "3.31.1", "react-native-svg": "15.2.0", diff --git a/apps/mobile/src/components/forms/ExpoPhotoSelector.tsx b/apps/mobile/src/components/forms/ExpoPhotoSelector.tsx index e6512cc..05dcb78 100644 --- a/apps/mobile/src/components/forms/ExpoPhotoSelector.tsx +++ b/apps/mobile/src/components/forms/ExpoPhotoSelector.tsx @@ -15,15 +15,20 @@ type ExpoPhotoSelectorProps = { recordId: string; photos: SelectedPhoto[]; setSelectedPhotos: (selectedPhotos: SelectedPhoto[]) => void; + setStatus: (status: "Idle" | "Loading") => void; }; export function ExpoPhotoSelector({ recordId, photos, setSelectedPhotos, + setStatus, }: ExpoPhotoSelectorProps) { const toast = useToast(); + const handleOnPress = async () => { + setStatus("Loading"); + try { const permission = await MediaLibrary.getPermissionsAsync(); @@ -44,7 +49,6 @@ export function ExpoPhotoSelector({ // TODO: should "cancel" unselect all? there is no way to go back to 0 selected after first selection if (!result.canceled) { - // TODO: start an ActivityIndicator const assets: MediaLibrary.Asset[] = []; await result.assets.reduce(async (promise, current) => { @@ -87,6 +91,8 @@ export function ExpoPhotoSelector({ message="We ran into an error preparing photos for upload." /> ); + } finally { + setStatus("Idle"); } }; diff --git a/apps/mobile/src/components/forms/GpsCoordinatesInput.tsx b/apps/mobile/src/components/forms/GpsCoordinatesInput.tsx index 7df7065..464c3d2 100644 --- a/apps/mobile/src/components/forms/GpsCoordinatesInput.tsx +++ b/apps/mobile/src/components/forms/GpsCoordinatesInput.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useReducer, useRef } from "react"; +import React, { useCallback, useEffect, useReducer, useRef } from "react"; import { Pressable, View } from "native-base"; import { Accuracy, @@ -131,19 +131,16 @@ export function GpsCoordinatesInput({ Number(coordinate.toFixed(precision)); // watch location callback - const onCoordinatesWatch = ({ - latitude, - longitude, - }: { - latitude: number; - longitude: number; - }) => { - const lat = clipCoordinate(latitude); - const long = clipCoordinate(longitude); - - dispatch({ type: "UPDATE", displayValue: `${lat}, ${long}` }); - setCoordinates({ latitude: lat, longitude: long }); - }; + const onCoordinatesWatch = useCallback( + ({ latitude, longitude }: { latitude: number; longitude: number }) => { + const lat = clipCoordinate(latitude); + const long = clipCoordinate(longitude); + + dispatch({ type: "UPDATE", displayValue: `${lat}, ${long}` }); + setCoordinates({ latitude: lat, longitude: long }); + }, + [dispatch, setCoordinates] + ); // typed user input const onCoordinatesChanged = (value: string) => { @@ -200,9 +197,7 @@ export function GpsCoordinatesInput({ })(); return stopWatchPosition; - // only subscribe to the location subscription on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [usingGps, onCoordinatesWatch]); const enableManualEntry = () => { stopWatchPosition(); diff --git a/apps/mobile/src/components/forms/KeyboardShift.tsx b/apps/mobile/src/components/forms/KeyboardShift.tsx deleted file mode 100644 index 925e622..0000000 --- a/apps/mobile/src/components/forms/KeyboardShift.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { Component } from "react"; -import { - Animated, - Dimensions, - EmitterSubscription, - Keyboard, - KeyboardEventListener, - Platform, - StyleSheet, - TextInput, -} from "react-native"; - -const styles = StyleSheet.create({ - container: { - height: "100%", - left: 0, - position: "absolute", - top: 0, - width: "100%", - }, -}); - -interface IProps { - children: () => React.ReactNode; -} - -interface IState { - shift: Animated.Value; -} - -const { State: TextInputState } = TextInput; - -export class KeyboardShift extends Component { - isHiding: Animated.CompositeAnimation | null; - - previousGap: number; - - keyboardDidShowSub: EmitterSubscription; - - keyboardDidHideSub: EmitterSubscription; - - constructor(props) { - super(props); - this.state = { - shift: new Animated.Value(0), - }; - this.previousGap = 0; - } - - componentDidMount() { - if (Platform.OS === "ios") { - this.keyboardDidShowSub = Keyboard.addListener( - "keyboardDidShow", - this.handleKeyboardDidShow - ); - this.keyboardDidHideSub = Keyboard.addListener( - "keyboardDidHide", - this.handleKeyboardDidHide - ); - } - } - - componentWillUnmount() { - if (Platform.OS === "ios") { - this.keyboardDidShowSub.remove(); - this.keyboardDidHideSub.remove(); - } - } - - handleKeyboardDidShow: KeyboardEventListener = (event) => { - const { shift } = this.state; - const { height: windowHeight } = Dimensions.get("window"); - // when the multiline TextInput grows, we want the keyboard to move with it - const keyboardHeight = event?.endCoordinates?.height; - const currentlyFocusedInput = TextInputState.currentlyFocusedInput(); - - if (this.isHiding) { - this.isHiding.stop(); - this.isHiding = null; - } - - if (currentlyFocusedInput != null) { - currentlyFocusedInput.measure( - (originX, originY, width, height, pageX, pageY) => { - const fieldHeight = height; - const fieldTop = pageY; - let gap = - windowHeight - keyboardHeight - (fieldTop + fieldHeight + 10); - - if (!gap) { - return; - } - - // negative gap means the currentlyFocusedInput is covered by Keyboard - if (gap < 0) { - gap += this.previousGap; - } - - // positive gap means the currentlyFocusedInput is not covered by Keyboard - if (gap >= 0) { - return; - } - - this.previousGap = gap; - Animated.timing(shift, { - toValue: gap, - duration: 250, - useNativeDriver: true, - }).start(); - } - ); - } - }; - - handleKeyboardDidHide = () => { - const { shift } = this.state; - this.isHiding = Animated.timing(shift, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }); - - // it is common, and unpredictable, that 'keyboardDidHide' events are fired even though the keyboard remained visible - // if this happens and 'keyboardDidShow' is simultaneously fired, we stop hiding animation from completing - this.isHiding.start(({ finished }) => { - this.isHiding = null; - if (finished) { - this.previousGap = 0; - } - }); - }; - - render() { - const { children } = this.props; - const { shift } = this.state; - - return ( - - {children()} - - ); - } -} diff --git a/apps/mobile/src/components/forms/index.ts b/apps/mobile/src/components/forms/index.ts index 725eb21..1e9dd74 100644 --- a/apps/mobile/src/components/forms/index.ts +++ b/apps/mobile/src/components/forms/index.ts @@ -2,7 +2,6 @@ export * from "./CustomTextInput"; export * from "./DateSelector"; export * from "./ExpoPhotoSelector"; export * from "./GpsCoordinatesInput"; -export * from "./KeyboardShift"; export * from "./PhotoSelector"; export * from "./TextArea"; export * from "./TypeSelector"; diff --git a/apps/mobile/src/components/screens/TimelineRow.tsx b/apps/mobile/src/components/screens/TimelineRow.tsx index 0615469..726292b 100644 --- a/apps/mobile/src/components/screens/TimelineRow.tsx +++ b/apps/mobile/src/components/screens/TimelineRow.tsx @@ -44,7 +44,7 @@ export function TimelineRow({ record, photos, actionsMenu }: TimelineRowProps) { return ( <> navigate("RecordDetails", { record: JSON.stringify(recordDetail) }) } diff --git a/apps/mobile/src/components/test/KeyboardShift.test.tsx b/apps/mobile/src/components/test/KeyboardShift.test.tsx deleted file mode 100644 index 148452b..0000000 --- a/apps/mobile/src/components/test/KeyboardShift.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { DeviceEventEmitter, Platform, TextInput } from "react-native"; -import { render } from "@testing-library/react-native"; -import { KeyboardShift } from "../forms"; - -const { State: TextInputState } = TextInput; - -describe("KeyboardShift test suite", () => { - test("renders on ios", () => { - const { toJSON } = render( - {() => } - ); - - expect(toJSON()).toMatchSnapshot(); - }); - - test("renders on android", () => { - Platform.OS = "android"; - const { toJSON, unmount } = render( - {() => } - ); - - expect(toJSON()).toMatchSnapshot(); - - unmount(); - - Platform.OS = "ios"; - }); - - test("emit keyboard events", async () => { - const event = { - endCoordinates: { - height: 50, - }, - }; - - render({() => }); - - // the best we can do is execute the code and make sure it doesn't crash - DeviceEventEmitter.emit("keyboardDidShow", {}); - - jest - .spyOn(TextInputState, "currentlyFocusedInput") - // @ts-ignore - .mockImplementation(() => ({ - measure: (cb) => { - cb(0, 0, 0, 50, 0, 50); - }, - })); - - // gap = NaN - DeviceEventEmitter.emit("keyboardDidShow", {}); - - // gap >= 0 - DeviceEventEmitter.emit("keyboardDidShow", event); - DeviceEventEmitter.emit("keyboardDidHide", {}); - - event.endCoordinates.height = 5000; - // gap < 0 - DeviceEventEmitter.emit("keyboardDidShow", event); - DeviceEventEmitter.emit("keyboardDidHide", {}); - - // wait for the keyboard hiding animation to finish - await new Promise((r) => { - setTimeout(r, 250); - }); - }); -}); diff --git a/apps/mobile/src/components/test/__snapshots__/KeyboardShift.test.tsx.snap b/apps/mobile/src/components/test/__snapshots__/KeyboardShift.test.tsx.snap deleted file mode 100644 index f46aa15..0000000 --- a/apps/mobile/src/components/test/__snapshots__/KeyboardShift.test.tsx.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`KeyboardShift test suite renders on android 1`] = ` - - - -`; - -exports[`KeyboardShift test suite renders on ios 1`] = ` - - - -`; diff --git a/apps/mobile/src/screens/RecordScreen.tsx b/apps/mobile/src/screens/RecordScreen.tsx index bf5e781..1a90727 100644 --- a/apps/mobile/src/screens/RecordScreen.tsx +++ b/apps/mobile/src/screens/RecordScreen.tsx @@ -1,7 +1,9 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, ScrollView } from "native-base"; import "react-native-get-random-values"; import { v4 as uuidv4 } from "uuid"; +import { AvoidSoftInput } from "react-native-avoid-softinput"; +import { useFocusEffect } from "@react-navigation/native"; import Logger from "@livingsnow/logger"; import { AlgaeRecord, @@ -24,7 +26,6 @@ import { DateSelector, ExpoPhotoSelector, GpsCoordinatesInput, - KeyboardShift, // PhotoSelector, TextArea, } from "../components/forms"; @@ -90,7 +91,9 @@ export function RecordScreen({ navigation, route }: RecordScreenProps) { const locationDescriptionRef = useRef(null); const toast = useToast(); - const [status, setStatus] = useState<"Idle" | "Uploading" | "Saving">("Idle"); + const [status, setStatus] = useState< + "Idle" | "Uploading" | "Saving" | "Loading" + >("Idle"); // prevents multiple events from quick taps const inHandler = useRef(false); @@ -110,6 +113,18 @@ export function RecordScreen({ navigation, route }: RecordScreenProps) { } ); + const onFocusEffect = React.useCallback(() => { + // This should be run when screen gains focus - enable the module where it's needed + AvoidSoftInput.setEnabled(true); + AvoidSoftInput.setAdjustPan(); + return () => { + // This should be run when screen loses focus - disable the module where it's not needed, to make a cleanup + AvoidSoftInput.setEnabled(false); + }; + }, []); + + useFocusEffect(onFocusEffect); // register callback to focus events + const [selectedPhotos, setSelectedPhotos] = useState([]); // navigation.navigate(...) from ImagesPickerScreen with selected photos @@ -242,120 +257,119 @@ export function RecordScreen({ navigation, route }: RecordScreenProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [record, selectedPhotos]); + const setCoordinates = useCallback( + ({ latitude, longitude }) => + setRecord((prev) => ({ ...prev, latitude, longitude })), + [setRecord] + ); + return ( <> - - {() => ( - - - setRecord((prev) => ({ ...prev, type }))} - /> - - - - setRecord((prev) => ({ - ...prev, - date: dateWithOffset(new Date(newDate), "add"), - })) - } - /> - - - - setRecord((prev) => ({ ...prev, latitude, longitude })) - } - onSubmitEditing={() => locationDescriptionRef.current?.focus()} - /> - - - { - setRecord((prev) => ({ ...prev, size })); - }} - /> - - - { - setRecord((prev) => ({ - ...prev, - colors: [...colors], - })); - }} - /> - - - - setRecord((prev) => ({ ...prev, tubeId })) - } - onSubmitEditing={() => locationDescriptionRef.current?.focus()} - /> - - -