diff --git a/messages/en.json b/messages/en.json index f242b7401..99c705f4e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -300,6 +300,118 @@ "description": "v is short for version. the translation for version can be used instead", "message": "V" }, + "screens.ManualGpsScreen.DdForm.east": { + "message": "East" + }, + "screens.ManualGpsScreen.DdForm.invalidCoordinates": { + "message": "Invalid coordinates" + }, + "screens.ManualGpsScreen.DdForm.latInputLabel": { + "message": "Latitude value" + }, + "screens.ManualGpsScreen.DdForm.latitude": { + "message": "Latitude" + }, + "screens.ManualGpsScreen.DdForm.lonInputLabel": { + "message": "Longitude value" + }, + "screens.ManualGpsScreen.DdForm.longitude": { + "message": "Longitude" + }, + "screens.ManualGpsScreen.DdForm.north": { + "message": "North" + }, + "screens.ManualGpsScreen.DdForm.selectLatCardinality": { + "message": "Select latitude cardinality" + }, + "screens.ManualGpsScreen.DdForm.selectLonCardinality": { + "message": "Select longitude cardinality" + }, + "screens.ManualGpsScreen.DdForm.south": { + "message": "South" + }, + "screens.ManualGpsScreen.DdForm.west": { + "message": "West" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.MinutesInputLabel": { + "message": "{field} minutes input" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.SecondsInputLabel": { + "message": "{field} seconds input" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.degrees": { + "message": "Degrees" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.degreesInputLabel": { + "message": "{field} degrees input" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.direction": { + "message": "Direction" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.minutes": { + "message": "Minutes" + }, + "screens.ManualGpsScreen.DmsForm.DmsInputGroup.seconds": { + "message": "Seconds" + }, + "screens.ManualGpsScreen.DmsForm.east": { + "message": "East" + }, + "screens.ManualGpsScreen.DmsForm.invalidCoordinates": { + "message": "Invalid coordinates" + }, + "screens.ManualGpsScreen.DmsForm.latitude": { + "message": "Latitude" + }, + "screens.ManualGpsScreen.DmsForm.longitude": { + "message": "Longitude" + }, + "screens.ManualGpsScreen.DmsForm.north": { + "message": "North" + }, + "screens.ManualGpsScreen.DmsForm.selectLatCardinality": { + "message": "Select latitude cardinality" + }, + "screens.ManualGpsScreen.DmsForm.south": { + "message": "South" + }, + "screens.ManualGpsScreen.DmsForm.west": { + "message": "West" + }, + "screens.ManualGpsScreen.coordinateFormat": { + "message": "Coordinate Format" + }, + "screens.ManualGpsScreen.decimalDegrees": { + "message": "Decimal Degrees (DD)" + }, + "screens.ManualGpsScreen.degreesMinutesSeconds": { + "message": "Degrees/Minutes/Seconds (DMS)" + }, + "screens.ManualGpsScreen.easting": { + "message": "East" + }, + "screens.ManualGpsScreen.eastingSuffix": { + "message": "mE" + }, + "screens.ManualGpsScreen.northing": { + "message": "North" + }, + "screens.ManualGpsScreen.northingSuffix": { + "message": "mN" + }, + "screens.ManualGpsScreen.title": { + "description": "title of manual GPS screen", + "message": "Enter coordinates" + }, + "screens.ManualGpsScreen.universalTransverseMercator": { + "message": "Universal Transverse Mercator (UTM)" + }, + "screens.ManualGpsScreen.zoneLetter": { + "message": "Zone Letter" + }, + "screens.ManualGpsScreen.zoneNumber": { + "message": "Zone Number" + }, "screens.ObscurePasscode.description": { "message": "Obscure Passcode is a security feature that allows you to open Mapeo in a decoy mode that hides all of your data. Entering the Obscure Passcode on the intro screen will display an empty version of Mapeo which allows you to create demonstration observations that are not saved to the Mapeo database." }, diff --git a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx index 93fd8c817..ce227132b 100644 --- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx +++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx @@ -61,6 +61,10 @@ import { SyncScreen, createNavigationOptions as createSyncNavOptions, } from '../../screens/Sync'; +import { + ManualGpsScreen, + createNavigationOptions as createManualGpsNavigationOptions, +} from '../../screens/ManualGpsScreen'; export const TAB_BAR_HEIGHT = 70; @@ -375,5 +379,10 @@ export const createDefaultScreenGroup = ( component={SyncScreen} options={createSyncNavOptions()} /> + ); diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts index 31cd495b0..b84f5acd9 100644 --- a/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts +++ b/src/frontend/hooks/persistedState/usePersistedDraftObservation/index.ts @@ -76,6 +76,7 @@ const draftObservationSlice: StateCreator = ( metadata: { ...prevValue.metadata, position: props.position, + manualLocation: props.manualLocation, }, }, }); diff --git a/src/frontend/hooks/persistedState/usePersistedSettings.ts b/src/frontend/hooks/persistedState/usePersistedSettings.ts index c00203d71..fcf7f2900 100644 --- a/src/frontend/hooks/persistedState/usePersistedSettings.ts +++ b/src/frontend/hooks/persistedState/usePersistedSettings.ts @@ -4,15 +4,22 @@ import {CoordinateFormat} from '../../sharedTypes'; type SettingsSlice = { coordinateFormat: CoordinateFormat; + manualCoordinateEntryFormat: CoordinateFormat; actions: { setCoordinateFormat: (coordinateFormat: CoordinateFormat) => void; + setManualCoordinateEntryFormat: ( + coordinateFormat: CoordinateFormat, + ) => void; }; }; const settingsSlice: StateCreator = (set, get) => ({ coordinateFormat: 'utm', + manualCoordinateEntryFormat: 'utm', actions: { setCoordinateFormat: coordinateFormat => set({coordinateFormat}), + setManualCoordinateEntryFormat: coordinateFormat => + set({manualCoordinateEntryFormat: coordinateFormat}), }, }); diff --git a/src/frontend/screens/ManualGpsScreen/DdForm.tsx b/src/frontend/screens/ManualGpsScreen/DdForm.tsx new file mode 100644 index 000000000..a358947d7 --- /dev/null +++ b/src/frontend/screens/ManualGpsScreen/DdForm.tsx @@ -0,0 +1,262 @@ +import * as React from 'react'; +import { + NativeSyntheticEvent, + StyleSheet, + TextInput, + TextInputEndEditingEventData, + View, +} from 'react-native'; +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; + +import {Text} from '../../sharedComponents/Text'; +import {Select} from '../../sharedComponents/Select'; +import {BLACK, LIGHT_GREY} from '../../lib/styles'; +import { + POSITIVE_DECIMAL_REGEX, + FormProps, + getInitialCardinality, + parseNumber, +} from './shared'; + +const MAX_COORDINATE_INPUT_LENGTH = 11; + +const m = defineMessages({ + invalidCoordinates: { + id: 'screens.ManualGpsScreen.DdForm.invalidCoordinates', + defaultMessage: 'Invalid coordinates', + }, + latitude: { + id: 'screens.ManualGpsScreen.DdForm.latitude', + defaultMessage: 'Latitude', + }, + longitude: { + id: 'screens.ManualGpsScreen.DdForm.longitude', + defaultMessage: 'Longitude', + }, + north: { + id: 'screens.ManualGpsScreen.DdForm.north', + defaultMessage: 'North', + }, + south: { + id: 'screens.ManualGpsScreen.DdForm.south', + defaultMessage: 'South', + }, + east: { + id: 'screens.ManualGpsScreen.DdForm.east', + defaultMessage: 'East', + }, + west: { + id: 'screens.ManualGpsScreen.DdForm.west', + defaultMessage: 'West', + }, + latInputLabel: { + id: 'screens.ManualGpsScreen.DdForm.latInputLabel', + defaultMessage: 'Latitude value', + }, + lonInputLabel: { + id: 'screens.ManualGpsScreen.DdForm.lonInputLabel', + defaultMessage: 'Longitude value', + }, + selectLatCardinality: { + id: 'screens.ManualGpsScreen.DdForm.selectLatCardinality', + defaultMessage: 'Select latitude cardinality', + }, + selectLonCardinality: { + id: 'screens.ManualGpsScreen.DdForm.selectLonCardinality', + defaultMessage: 'Select longitude cardinality', + }, +}); + +export const DdForm = ({initialCoordinates, onValueUpdate}: FormProps) => { + const {formatMessage: t} = useIntl(); + + const DIRECTION_OPTIONS_NORTH_SOUTH = [ + { + value: 'N', + label: t(m.north), + }, + { + value: 'S', + label: t(m.south), + }, + ]; + + const DIRECTION_OPTIONS_EAST_WEST = [ + { + value: 'E', + label: t(m.east), + }, + { + value: 'W', + label: t(m.west), + }, + ]; + + const [latitudeDegrees, setLatitudeDegrees] = React.useState(''); + const [longitudeDegrees, setLongitudeDegrees] = React.useState(''); + + const [selectedLatCardinality, setSelectedLatCardinality] = React.useState( + getInitialCardinality('lat', initialCoordinates), + ); + const [selectedLonCardinality, setSelectedLonCardinality] = React.useState( + getInitialCardinality('lon', initialCoordinates), + ); + + const parsedLat = parseNumber(latitudeDegrees); + const parsedLon = parseNumber(longitudeDegrees); + + const signedLat = + parsedLat !== undefined + ? parsedLat * (selectedLatCardinality === 'N' ? 1 : -1) + : undefined; + const signedLon = + parsedLon !== undefined + ? parsedLon * (selectedLonCardinality === 'E' ? 1 : -1) + : undefined; + + React.useEffect(() => { + try { + const latIsValidRange = + signedLat !== undefined && Math.abs(signedLat) <= 90; + const lonIsValidRange = + signedLon !== undefined && Math.abs(signedLon) <= 180; + + if (latIsValidRange && lonIsValidRange) { + onValueUpdate({ + coords: { + lat: signedLat, + lon: signedLon, + }, + }); + } else { + throw new Error(t(m.invalidCoordinates)); + } + } catch (err) { + if (err instanceof Error) { + onValueUpdate({ + error: err, + }); + } + } + }, [t, signedLat, signedLon, onValueUpdate]); + + const validateOnChange = + (setState: (v: string) => void) => (text: string) => { + const isJustDecimal = text === '.'; + const endsWithDecimal = text.endsWith('.'); + + if ( + text.length === 0 || + isJustDecimal || + endsWithDecimal || + POSITIVE_DECIMAL_REGEX.test(text) + ) { + setState(text); + } else if (Number.parseFloat(text) === 0) { + setState('0'); + } + }; + + const formatInputValue = + (setState: (v: string) => void) => + ({ + nativeEvent: {text}, + }: NativeSyntheticEvent) => { + const parsed = Number.parseFloat(text); + if (!Number.isNaN(parsed)) { + setState(parsed.toString()); + } + }; + + return ( + + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + inputLabel: { + fontWeight: 'bold', + color: BLACK, + }, + input: { + borderColor: LIGHT_GREY, + borderWidth: 1, + padding: 10, + fontSize: 20, + marginTop: 10, + }, + row: { + marginBottom: 40, + flexDirection: 'row', + alignItems: 'flex-end', + }, + column: { + flex: 1, + marginHorizontal: 10, + width: '50%', + }, + select: { + marginTop: 10, + }, +}); diff --git a/src/frontend/screens/ManualGpsScreen/DmsForm/DmsInputGroup.tsx b/src/frontend/screens/ManualGpsScreen/DmsForm/DmsInputGroup.tsx new file mode 100644 index 000000000..3bc513d14 --- /dev/null +++ b/src/frontend/screens/ManualGpsScreen/DmsForm/DmsInputGroup.tsx @@ -0,0 +1,231 @@ +import * as React from 'react'; +import { + NativeSyntheticEvent, + StyleSheet, + TextInput, + TextInputEndEditingEventData, + View, +} from 'react-native'; +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; + +import {Text} from '../../../sharedComponents/Text'; +import {Select} from '../../../sharedComponents/Select'; +import {BLACK, LIGHT_GREY} from '../../../lib/styles'; +import {INTEGER_REGEX, parseNumber} from '../shared'; +import {DmsData, DmsUnit} from './index'; + +const m = defineMessages({ + degrees: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.degrees', + defaultMessage: 'Degrees', + }, + minutes: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.minutes', + defaultMessage: 'Minutes', + }, + seconds: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.seconds', + defaultMessage: 'Seconds', + }, + degreesInputLabel: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.degreesInputLabel', + defaultMessage: '{field} degrees input', + }, + minutesInputLabel: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.MinutesInputLabel', + defaultMessage: '{field} minutes input', + }, + secondsInputLabel: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.SecondsInputLabel', + defaultMessage: '{field} seconds input', + }, + direction: { + id: 'screens.ManualGpsScreen.DmsForm.DmsInputGroup.direction', + defaultMessage: 'Direction', + }, +}); + +interface Props { + cardinalityOptions: {label: string; value: string}[]; + coordinate: DmsData; + inputAccessibilityLabelPrefix: string; + label: React.ReactNode; + selectedCardinality: string; + selectCardinaltiyAccessibilityLabel: string; + selectTestID: string; + updateCardinality: (value: string) => void; + updateCoordinate: (unit: DmsUnit, value: string) => void; +} + +export const DmsInputGroup = ({ + cardinalityOptions, + coordinate, + inputAccessibilityLabelPrefix, + label, + selectedCardinality, + selectCardinaltiyAccessibilityLabel, + selectTestID, + updateCardinality, + updateCoordinate, +}: Props) => { + const {formatMessage: t} = useIntl(); + + const updateCoordinateCallback = React.useCallback( + (unit: DmsUnit) => (value: string) => { + if (value.length === 0) { + updateCoordinate(unit, value); + return; + } + + if (unit === 'seconds' && !value.includes('-')) { + updateCoordinate(unit, value); + } else { + if (INTEGER_REGEX.test(value)) { + updateCoordinate(unit, value); + } + } + }, + [updateCoordinate], + ); + + const formatInputValue = + (unit: DmsUnit) => + ({ + nativeEvent: {text}, + }: NativeSyntheticEvent) => { + const formatted = parseNumber(text); + if (formatted !== undefined) { + updateCoordinateCallback(unit)(formatted.toString()); + } + }; + + return ( + + + + {label} + + + + + + + + {'°'} + + + + + + + {"'"} + + + + + + + {'"'} + + + + + + : + + { + if (typeof value === 'number' || !isCoordinateFormat(value)) { + return; + } + + setManualCoordinateEntryFormat(value); + }} + options={ENTRY_FORMAT_OPTIONS} + selectedValue={entryCoordinateFormat} + /> + + + + {entryCoordinateFormat === 'dd' ? ( + + ) : entryCoordinateFormat === 'dms' ? ( + + ) : ( + + )} + + + + + ); +}; + +export function createNavigationOptions({ + intl, +}: { + intl: (title: MessageDescriptor) => string; +}) { + return (): NativeStackNavigationOptions => { + return { + headerTitle: intl(m.title), + headerRight: () => ( + + + + ), + }; + }; +} + +function entryCoordinateFormatSelector( + state: Parameters[0]>[0], +) { + return state.manualCoordinateEntryFormat; +} + +function observationValueSelector( + state: Parameters[0]>[0], +) { + return state.value; +} + +function isCoordinateFormat(value: string): value is CoordinateFormat { + return value === 'dd' || value === 'dms' || value === 'utm'; +} + +const styles = StyleSheet.create({ + scrollContentContainer: { + paddingHorizontal: 10, + paddingVertical: 20, + }, + selectContainer: { + marginVertical: 10, + }, + inputLabel: { + fontWeight: 'bold', + color: BLACK, + }, + formatSelect: { + marginHorizontal: 10, + }, + formContainer: { + marginVertical: 20, + }, +}); diff --git a/src/frontend/screens/ManualGpsScreen/shared.ts b/src/frontend/screens/ManualGpsScreen/shared.ts new file mode 100644 index 000000000..b152b04b4 --- /dev/null +++ b/src/frontend/screens/ManualGpsScreen/shared.ts @@ -0,0 +1,40 @@ +export type CoordinateField = 'lat' | 'lon'; + +type Coordinates = {lat?: number; lon?: number}; + +export type ConvertedCoordinateData = { + coords?: Coordinates; + error?: Error; +}; + +export type FormProps = { + initialCoordinates?: {lat: number; lon: number}; + onValueUpdate: (convertedCoordinates: ConvertedCoordinateData) => void; +}; + +// Adapted from https://stackoverflow.com/a/7708352 +export const POSITIVE_DECIMAL_REGEX = /^([\d]+(?:[\.][\d]*)?|\.[\d]+)$/; + +export const INTEGER_REGEX = /^[0-9]\d*$/; + +export function parseNumber(str: string): number | undefined { + const num = Number.parseFloat(str); + return Number.isNaN(num) ? undefined : num; +} + +export function getInitialCardinality( + field: CoordinateField, + coords?: Coordinates, +) { + if (field === 'lat') { + if (typeof coords?.lat !== 'number') { + return 'N'; + } + return coords.lat >= 0 ? 'N' : 'S'; + } else { + if (typeof coords?.lon !== 'number') { + return 'E'; + } + return coords.lon >= 0 ? 'E' : 'W'; + } +} diff --git a/src/frontend/screens/ObservationEdit/LocationView.tsx b/src/frontend/screens/ObservationEdit/LocationView.tsx index ea5dd55c3..8c82387b4 100644 --- a/src/frontend/screens/ObservationEdit/LocationView.tsx +++ b/src/frontend/screens/ObservationEdit/LocationView.tsx @@ -6,6 +6,7 @@ import {BLACK, LIGHT_GREY} from '../../lib/styles'; import {useMostAccurateLocationForObservation} from './useMostAccurateLocationForObservation'; import {FormattedCoords} from '../../sharedComponents/FormattedData'; +import {usePersistedDraftObservation} from '../../hooks/persistedState/usePersistedDraftObservation'; import {usePersistedSettings} from '../../hooks/persistedState/usePersistedSettings'; const m = defineMessages({ @@ -17,20 +18,27 @@ const m = defineMessages({ }); export const LocationView = () => { - const location = useMostAccurateLocationForObservation(); - const coordinateFormat = usePersistedSettings( - store => store.coordinateFormat, + const liveLocation = useMostAccurateLocationForObservation(); + const observationValue = usePersistedDraftObservation( + observationValueSelector, ); - const lat = - !location || !location.coords ? undefined : location.coords.latitude; - const lon = - !location || !location.coords ? undefined : location.coords.longitude; - const accuracy = - !location || !location.coords ? undefined : location.coords.accuracy; + const coordinateFormat = usePersistedSettings(coordinateFormatSelector); + + const coordinateInfo = observationValue?.metadata.manualLocation + ? { + lat: observationValue.lat, + lon: observationValue.lon, + accuracy: liveLocation?.coords?.accuracy, + } + : { + lat: liveLocation?.coords?.latitude, + lon: liveLocation?.coords?.longitude, + accuracy: liveLocation?.coords?.accuracy, + }; return ( - {!lat || !lon ? ( + {coordinateInfo.lat === undefined || coordinateInfo.lon === undefined ? ( @@ -43,11 +51,15 @@ export const LocationView = () => { style={{marginRight: 5}} /> - + - {!accuracy ? null : ( + {coordinateInfo.accuracy === undefined ? null : ( - {' ±' + accuracy.toFixed(2) + 'm'} + {' ±' + coordinateInfo.accuracy.toFixed(2) + 'm'} )} @@ -56,6 +68,18 @@ export const LocationView = () => { ); }; +function observationValueSelector( + state: Parameters[0]>[0], +) { + return state.value; +} + +function coordinateFormatSelector( + state: Parameters[0]>[0], +) { + return state.coordinateFormat; +} + const styles = StyleSheet.create({ locationContainer: { flex: 0, diff --git a/src/frontend/screens/ObservationEdit/SaveButton.tsx b/src/frontend/screens/ObservationEdit/SaveButton.tsx index 5b4a2f724..a9440a01b 100644 --- a/src/frontend/screens/ObservationEdit/SaveButton.tsx +++ b/src/frontend/screens/ObservationEdit/SaveButton.tsx @@ -195,7 +195,7 @@ export const SaveButton = ({ }, { text: t(m.manualEntry), - onPress: () => {}, + onPress: () => navigation.navigate('ManualGpsScreen'), style: 'cancel', }, { @@ -213,11 +213,12 @@ export const SaveButton = ({ return; } - const hasLocation = value.lat && value.lon; + const hasLocation = value.lat !== undefined && value.lon !== undefined; const locationSetManually = value.metadata.manualLocation; if ( - locationSetManually || - (hasLocation && isGpsAccurate(value.metadata.position?.coords?.accuracy)) + hasLocation && + (locationSetManually || + isGpsAccurate(value.metadata.position?.coords?.accuracy)) ) { // Observation has a location, which is either from an accurate GPS // reading, or is manually entered diff --git a/src/frontend/screens/ObservationEdit/useMostAccurateLocationForObservation.ts b/src/frontend/screens/ObservationEdit/useMostAccurateLocationForObservation.ts index 74a034538..386b82e78 100644 --- a/src/frontend/screens/ObservationEdit/useMostAccurateLocationForObservation.ts +++ b/src/frontend/screens/ObservationEdit/useMostAccurateLocationForObservation.ts @@ -20,13 +20,21 @@ export function useMostAccurateLocationForObservation() { const locationServicesTurnedOff = providerStatus && !providerStatus.locationServicesEnabled; - if (locationServicesTurnedOff && !value?.metadata.position) { + const isLocationManuallySet = !!value?.metadata.manualLocation; + + // If location services are turned off (and the observation location is not manually set), + // we want to immediately update the draft so that this hook does not return a stale position + if ( + locationServicesTurnedOff && + value?.metadata.position && + !isLocationManuallySet + ) { updateObservationPosition({position: undefined, manualLocation: false}); } useFocusEffect( useCallback(() => { - if (!permissions || !permissions.granted) return; + if (!permissions || !permissions.granted || isLocationManuallySet) return; let ignore = false; const locationSubscriptionProm = watchPositionAsync( @@ -61,7 +69,7 @@ export function useMostAccurateLocationForObservation() { ignore = true; locationSubscriptionProm.then(sub => sub.remove()); }; - }, [permissions, updateObservationPosition]), + }, [permissions, updateObservationPosition, isLocationManuallySet]), ); return value?.metadata.position; diff --git a/src/frontend/sharedComponents/IconButton.tsx b/src/frontend/sharedComponents/IconButton.tsx index b45b81031..872c817b8 100644 --- a/src/frontend/sharedComponents/IconButton.tsx +++ b/src/frontend/sharedComponents/IconButton.tsx @@ -7,7 +7,7 @@ import type {ViewStyleProp} from '../sharedTypes'; type Props = { children: React.ReactNode; - onPress: ((event: GestureResponderEvent) => void) | (() => void); + onPress?: ((event: GestureResponderEvent) => void) | (() => void); style?: ViewStyleProp; testID?: string; };