diff --git a/messages/en.json b/messages/en.json
index 8534d05ca..adac9f27d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -279,6 +279,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 919b29e19..c8077bee1 100644
--- a/src/frontend/Navigation/ScreenGroups/AppScreens.tsx
+++ b/src/frontend/Navigation/ScreenGroups/AppScreens.tsx
@@ -55,6 +55,10 @@ import {useLocationProviderStatus} from '../../hooks/useLocationProviderStatus';
import {getLocationStatus} from '../../lib/utils';
import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined';
import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite';
+import {
+ ManualGpsScreen,
+ createNavigationOptions as createManualGpsNavigationOptions,
+} from '../../screens/ManualGpsScreen';
export type HomeTabsList = {
Map: undefined;
@@ -347,5 +351,10 @@ export const createDefaultScreenGroup = (
component={UnableToCancelInvite}
options={{headerShown: false}}
/>
+
);
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}
+
+
+
+
+
+
+
+ {'°'}
+
+
+
+
+
+
+ {"'"}
+
+
+
+
+
+
+ {'"'}
+
+
+
+
+
+ :
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ groupContainer: {
+ marginBottom: 40,
+ marginHorizontal: 10,
+ },
+ inputsContainer: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ },
+ inputContainer: {
+ flex: 1,
+ },
+ inputLabel: {
+ fontWeight: 'bold',
+ color: BLACK,
+ },
+ input: {
+ borderColor: LIGHT_GREY,
+ borderWidth: 1,
+ padding: 10,
+ fontSize: 20,
+ marginTop: 10,
+ marginBottom: 5,
+ },
+ row: {
+ marginBottom: 20,
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ },
+ column: {
+ flex: 1,
+ },
+ suffix: {
+ fontSize: 20,
+ marginLeft: 5,
+ marginRight: 10,
+ paddingTop: 5,
+ },
+ directionSelectContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+});
diff --git a/src/frontend/screens/ManualGpsScreen/DmsForm/index.tsx b/src/frontend/screens/ManualGpsScreen/DmsForm/index.tsx
new file mode 100644
index 000000000..504ee1c91
--- /dev/null
+++ b/src/frontend/screens/ManualGpsScreen/DmsForm/index.tsx
@@ -0,0 +1,210 @@
+import * as React from 'react';
+import {View} from 'react-native';
+import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
+
+import {convertDmsToDd} from '../../../lib/utils';
+import {
+ CoordinateField,
+ FormProps,
+ getInitialCardinality,
+ parseNumber,
+} from '../shared';
+import {DmsInputGroup} from './DmsInputGroup';
+
+const INITIAL_UNIT_VALUES = {
+ degrees: '',
+ minutes: '',
+ seconds: '',
+};
+
+export type DmsData = {
+ degrees: string;
+ minutes: string;
+ seconds: string;
+};
+
+export type DmsUnit = keyof DmsData;
+
+const m = defineMessages({
+ invalidCoordinates: {
+ id: 'screens.ManualGpsScreen.DmsForm.invalidCoordinates',
+ defaultMessage: 'Invalid coordinates',
+ },
+ latitude: {
+ id: 'screens.ManualGpsScreen.DmsForm.latitude',
+ defaultMessage: 'Latitude',
+ },
+ longitude: {
+ id: 'screens.ManualGpsScreen.DmsForm.longitude',
+ defaultMessage: 'Longitude',
+ },
+ north: {
+ id: 'screens.ManualGpsScreen.DmsForm.north',
+ defaultMessage: 'North',
+ },
+ south: {
+ id: 'screens.ManualGpsScreen.DmsForm.south',
+ defaultMessage: 'South',
+ },
+ east: {
+ id: 'screens.ManualGpsScreen.DmsForm.east',
+ defaultMessage: 'East',
+ },
+ west: {
+ id: 'screens.ManualGpsScreen.DmsForm.west',
+ defaultMessage: 'West',
+ },
+ selectLatCardinality: {
+ id: 'screens.ManualGpsScreen.DmsForm.selectLatCardinality',
+ defaultMessage: 'Select latitude cardinality',
+ },
+ selectLonCardinality: {
+ id: 'screens.ManualGpsScreen.DdForm.selectLonCardinality',
+ defaultMessage: 'Select longitude cardinality',
+ },
+});
+
+export const DmsForm = ({initialCoordinates, onValueUpdate}: FormProps) => {
+ const {formatMessage: t} = useIntl();
+
+ const DIRECTION_OPTIONS_NORTH_SOUTH = React.useMemo(
+ () => [
+ {
+ value: 'N',
+ label: t(m.north),
+ },
+ {
+ value: 'S',
+ label: t(m.south),
+ },
+ ],
+ [t],
+ );
+
+ const DIRECTION_OPTIONS_EAST_WEST = React.useMemo(
+ () => [
+ {
+ value: 'E',
+ label: t(m.east),
+ },
+ {
+ value: 'W',
+ label: t(m.west),
+ },
+ ],
+ [t],
+ );
+
+ const [latitude, setLatitude] = React.useState(INITIAL_UNIT_VALUES);
+ const [longitude, setLongitude] =
+ React.useState(INITIAL_UNIT_VALUES);
+
+ const [latCardinality, setLatCardinality] = React.useState(
+ getInitialCardinality('lat', initialCoordinates),
+ );
+ const [lonCardinality, setLonCardinality] = React.useState(
+ getInitialCardinality('lon', initialCoordinates),
+ );
+
+ const updateCoordinate =
+ (field: CoordinateField) => (unit: DmsUnit, value: string) => {
+ const update = field === 'lat' ? setLatitude : setLongitude;
+ update(previous => ({...previous, [unit]: value}));
+ };
+
+ const updateCardinality = (field: CoordinateField) => (value: string) =>
+ field === 'lat' ? setLatCardinality(value) : setLonCardinality(value);
+
+ React.useEffect(() => {
+ try {
+ if (
+ dmsValuesAreValid('lat', latitude) &&
+ dmsValuesAreValid('lon', longitude)
+ ) {
+ const parsedDmsLat = getParsedDmsValues(latitude);
+ const parsedDmsLon = getParsedDmsValues(longitude);
+
+ if (parsedDmsLat && parsedDmsLon) {
+ onValueUpdate({
+ coords: {
+ lat:
+ convertDmsToDd(parsedDmsLat) *
+ (latCardinality === 'N' ? 1 : -1),
+ lon:
+ convertDmsToDd(parsedDmsLon) *
+ (lonCardinality === 'E' ? 1 : -1),
+ },
+ });
+ }
+ } else {
+ throw new Error(t(m.invalidCoordinates));
+ }
+ } catch (err) {
+ if (err instanceof Error) {
+ onValueUpdate({
+ error: err,
+ });
+ }
+ }
+ }, [t, latitude, longitude, latCardinality, lonCardinality, onValueUpdate]);
+
+ return (
+
+ }
+ selectCardinaltiyAccessibilityLabel={t(m.selectLatCardinality)}
+ selectedCardinality={latCardinality}
+ selectTestID="DmsInputGroup-lat-select"
+ updateCardinality={updateCardinality('lat')}
+ updateCoordinate={updateCoordinate('lat')}
+ />
+
+ }
+ selectCardinaltiyAccessibilityLabel={t(m.selectLonCardinality)}
+ selectedCardinality={lonCardinality}
+ selectTestID="DmsInputGroup-lon-select"
+ updateCardinality={updateCardinality('lon')}
+ updateCoordinate={updateCoordinate('lon')}
+ />
+
+ );
+};
+
+function getParsedDmsValues({degrees, minutes, seconds}: DmsData) {
+ const degreesParsed = parseNumber(degrees);
+ const minutesParsed = parseNumber(minutes);
+ const secondsParsed = parseNumber(seconds);
+
+ if (
+ degreesParsed !== undefined &&
+ minutesParsed !== undefined &&
+ secondsParsed !== undefined
+ ) {
+ return {
+ degrees: degreesParsed,
+ minutes: minutesParsed,
+ seconds: secondsParsed,
+ };
+ }
+}
+
+function dmsValuesAreValid(field: CoordinateField, coordinate: DmsData) {
+ const parsedDms = getParsedDmsValues(coordinate);
+
+ if (parsedDms) {
+ const {degrees, minutes, seconds} = parsedDms;
+
+ const degreeMaximum = field === 'lat' ? 90 : 180;
+
+ return degrees <= degreeMaximum && minutes < 60 && seconds < 60;
+ } else {
+ return false;
+ }
+}
diff --git a/src/frontend/screens/ManualGpsScreen/UtmForm.tsx b/src/frontend/screens/ManualGpsScreen/UtmForm.tsx
new file mode 100644
index 000000000..6dd452150
--- /dev/null
+++ b/src/frontend/screens/ManualGpsScreen/UtmForm.tsx
@@ -0,0 +1,290 @@
+import * as React from 'react';
+import {toLatLon as origToLatLon, fromLatLon} from 'utm';
+import {View, TextInput, StyleSheet} from 'react-native';
+import {defineMessages, FormattedMessage, useIntl} from 'react-intl';
+
+import {Text} from '../../sharedComponents/Text';
+import {BLACK, LIGHT_GREY} from '../../lib/styles';
+import {FormProps, parseNumber} from './shared';
+
+const m = defineMessages({
+ zoneNumber: {
+ id: 'screens.ManualGpsScreen.zoneNumber',
+ defaultMessage: 'Zone Number',
+ },
+ zoneLetter: {
+ id: 'screens.ManualGpsScreen.zoneLetter',
+ defaultMessage: 'Zone Letter',
+ },
+ easting: {
+ id: 'screens.ManualGpsScreen.easting',
+ defaultMessage: 'East',
+ },
+ eastingSuffix: {
+ id: 'screens.ManualGpsScreen.eastingSuffix',
+ defaultMessage: 'mE',
+ },
+ northing: {
+ id: 'screens.ManualGpsScreen.northing',
+ defaultMessage: 'North',
+ },
+ northingSuffix: {
+ id: 'screens.ManualGpsScreen.northingSuffix',
+ defaultMessage: 'mN',
+ },
+});
+
+export const UtmForm = ({initialCoordinates, onValueUpdate}: FormProps) => {
+ const {formatMessage: t} = useIntl();
+
+ const [zoneNum, setZoneNum] = React.useState(() => {
+ if (
+ typeof initialCoordinates?.lat === 'number' &&
+ typeof initialCoordinates?.lon === 'number'
+ ) {
+ try {
+ return (
+ fromLatLon(initialCoordinates.lat, initialCoordinates.lon).zoneNum +
+ ''
+ );
+ } catch (e) {
+ return '';
+ }
+ } else {
+ return '';
+ }
+ });
+
+ const [zoneLetter, setZoneLetter] = React.useState(() => {
+ if (
+ typeof initialCoordinates?.lat === 'number' &&
+ typeof initialCoordinates?.lon === 'number'
+ ) {
+ try {
+ return fromLatLon(initialCoordinates.lat, initialCoordinates.lon)
+ .zoneLetter;
+ } catch (e) {
+ return '';
+ }
+ } else {
+ return '';
+ }
+ });
+
+ const [easting, setEasting] = React.useState('');
+ const [northing, setNorthing] = React.useState('');
+
+ React.useEffect(() => {
+ try {
+ const {latitude, longitude} = toLatLon({
+ easting,
+ northing,
+ zoneLetter,
+ zoneNum,
+ });
+
+ onValueUpdate({
+ coords: {
+ lat: latitude,
+ lon: longitude,
+ },
+ });
+ } catch (err) {
+ if (err instanceof Error) {
+ onValueUpdate({
+ error: err,
+ });
+ }
+ }
+ }, [easting, northing, zoneLetter, zoneNum, onValueUpdate]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ setZoneLetter(letter.trim().toUpperCase())}
+ maxLength={1}
+ autoCapitalize="characters"
+ style={styles.input}
+ value={zoneLetter}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+function toLatLon({
+ easting,
+ northing,
+ zoneLetter,
+ zoneNum,
+}: {
+ easting: string;
+ northing: string;
+ zoneLetter: string;
+ zoneNum: string;
+}) {
+ const parsedNorthing = parseNumber(northing);
+ let northern: boolean | undefined;
+ // There are two conventions for UTM. One uses a letter to refer to latitude
+ // bands from C to X, excluding "I" and "O". The other uses "N" or "S" to
+ // refer to the northern or southern hemisphere. If the user enters "N" or
+ // "S" we do not know which convention they are using, so we guess. We try
+ // to use the latitude band if we can, since it is better for catching
+ // errors in coordinate entry.
+ if (zoneLetter === 'S') {
+ // "S" could refer to grid zone "S" (in the northern hemisphere) or it
+ // could mean "Southern" - conventions differ in different places
+ const startOfZoneS = 3544369.909548157;
+ const startOfZoneT = 4432069.057005376;
+ if (
+ parsedNorthing !== undefined &&
+ parsedNorthing >= startOfZoneS &&
+ parsedNorthing < startOfZoneT
+ ) {
+ // Indeterminate, this could be latitude band S, or it could mean
+ // southern hemisphere. The only place in the southern hemisphere that
+ // matches these coordinates is the very southern tip of Chile and
+ // Argentina, so assume that in this case zoneLetter "S" refers to
+ // latitude band "S", in the northern hemisphere.
+ // TODO: Check with the user what they mean, or use last known location
+ } else {
+ // The northing is not within the range of grid zone "S", so we assume
+ // the user meant "Southern" with the letter "S"
+ northern = false;
+ }
+ } else if (zoneLetter === 'N') {
+ const startOfZoneN = 0;
+ const startOfZoneP = 885503.7592863895;
+ if (
+ parsedNorthing != null &&
+ parsedNorthing >= startOfZoneN &&
+ parsedNorthing < startOfZoneP
+ ) {
+ // Definitely in latitude band N, just use the band letter
+ } else {
+ // Outside latitude band "N", so the user probably means "Northern"
+ northern = true;
+ }
+ }
+
+ const numericEasting = parseNumber(easting) as number;
+ const numericNorthing = parseNumber(northing) as number;
+ const numericZoneNum = parseNumber(zoneNum) as number;
+
+ // If northern defined, then don't use the zoneLetter.
+ if (northern !== undefined) {
+ return origToLatLon(
+ numericEasting,
+ numericNorthing,
+ numericZoneNum,
+ undefined,
+ northern,
+ );
+ } else {
+ return origToLatLon(
+ numericEasting,
+ numericNorthing,
+ numericZoneNum,
+ zoneLetter,
+ );
+ }
+}
+
+const styles = StyleSheet.create({
+ inputLabel: {
+ fontWeight: 'bold',
+ color: BLACK,
+ },
+ inputContainer: {flexDirection: 'row', alignItems: 'center'},
+ input: {
+ flex: 1,
+ borderColor: LIGHT_GREY,
+ borderWidth: 1,
+ padding: 10,
+ fontSize: 20,
+ marginTop: 10,
+ },
+ row: {
+ flexDirection: 'row',
+ marginBottom: 20,
+ alignItems: 'flex-end',
+ },
+ column: {
+ flex: 1,
+ marginHorizontal: 10,
+ width: '50%',
+ },
+ suffix: {
+ fontSize: 20,
+ marginLeft: 5,
+ paddingTop: 5,
+ },
+});
diff --git a/src/frontend/screens/ManualGpsScreen/index.tsx b/src/frontend/screens/ManualGpsScreen/index.tsx
new file mode 100644
index 000000000..146b9c97d
--- /dev/null
+++ b/src/frontend/screens/ManualGpsScreen/index.tsx
@@ -0,0 +1,231 @@
+import * as React from 'react';
+import {
+ View,
+ ScrollView,
+ StyleSheet,
+ ToastAndroid,
+ KeyboardAvoidingView,
+} from 'react-native';
+import {
+ FormattedMessage,
+ MessageDescriptor,
+ defineMessages,
+ useIntl,
+} from 'react-intl';
+import {NativeStackNavigationOptions} from '@react-navigation/native-stack';
+
+import {useDraftObservation} from '../../hooks/useDraftObservation';
+import {
+ usePersistedSettings,
+ usePersistedSettingsAction,
+} from '../../hooks/persistedState/usePersistedSettings';
+import {BLACK} from '../../lib/styles';
+import {IconButton} from '../../sharedComponents/IconButton';
+import {SaveIcon} from '../../sharedComponents/icons';
+import {Select} from '../../sharedComponents/Select';
+import {Text} from '../../sharedComponents/Text';
+import {
+ type NativeRootNavigationProps,
+ type CoordinateFormat,
+} from '../../sharedTypes';
+
+import {type ConvertedCoordinateData} from './shared';
+import {DdForm} from './DdForm';
+import {DmsForm} from './DmsForm';
+import {UtmForm} from './UtmForm';
+import {usePersistedDraftObservation} from '../../hooks/persistedState/usePersistedDraftObservation';
+
+const m = defineMessages({
+ title: {
+ id: 'screens.ManualGpsScreen.title',
+ defaultMessage: 'Enter coordinates',
+ description: 'title of manual GPS screen',
+ },
+ coordinateFormat: {
+ id: 'screens.ManualGpsScreen.coordinateFormat',
+ defaultMessage: 'Coordinate Format',
+ },
+ decimalDegrees: {
+ id: 'screens.ManualGpsScreen.decimalDegrees',
+ defaultMessage: 'Decimal Degrees (DD)',
+ },
+ degreesMinutesSeconds: {
+ id: 'screens.ManualGpsScreen.degreesMinutesSeconds',
+ defaultMessage: 'Degrees/Minutes/Seconds (DMS)',
+ },
+ universalTransverseMercator: {
+ id: 'screens.ManualGpsScreen.universalTransverseMercator',
+ defaultMessage: 'Universal Transverse Mercator (UTM)',
+ },
+});
+
+export const ManualGpsScreen = ({
+ navigation,
+}: NativeRootNavigationProps<'ManualGpsScreen'>) => {
+ const {formatMessage: t} = useIntl();
+
+ const [convertedData, setConvertedData] =
+ React.useState({});
+
+ const ENTRY_FORMAT_OPTIONS = React.useMemo(
+ () => [
+ {label: t(m.decimalDegrees), value: 'dd'},
+ {label: t(m.degreesMinutesSeconds), value: 'dms'},
+ {label: t(m.universalTransverseMercator), value: 'utm'},
+ ],
+ [t],
+ );
+
+ const observationValue = usePersistedDraftObservation(
+ observationValueSelector,
+ );
+
+ const entryCoordinateFormat = usePersistedSettings(
+ entryCoordinateFormatSelector,
+ );
+
+ const {setManualCoordinateEntryFormat} = usePersistedSettingsAction();
+ const {updateObservationPosition} = useDraftObservation();
+
+ React.useLayoutEffect(() => {
+ function handleSavePress() {
+ if (convertedData.error) {
+ return ToastAndroid.showWithGravity(
+ convertedData.error.message,
+ ToastAndroid.LONG,
+ ToastAndroid.TOP,
+ );
+ }
+
+ updateObservationPosition({
+ position: {
+ mocked: false,
+ coords: {
+ latitude: convertedData.coords?.lat,
+ longitude: convertedData.coords?.lon,
+ },
+ },
+ manualLocation: true,
+ });
+
+ navigation.pop();
+ }
+
+ navigation.setOptions({
+ headerRight: () => (
+
+
+
+ ),
+ });
+ }, [navigation, convertedData, updateObservationPosition]);
+
+ const locationCoordinates =
+ observationValue &&
+ observationValue.lat !== undefined &&
+ observationValue.lon !== undefined
+ ? {
+ lat: observationValue.lat,
+ lon: observationValue.lon,
+ }
+ : undefined;
+
+ return (
+
+ {/* TODO: Set `behavior` to "padding" when iOS is supported */}
+
+
+
+
+
+
+
+
+
+ {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 c3632111f..f59d0177e 100644
--- a/src/frontend/screens/ObservationEdit/SaveButton.tsx
+++ b/src/frontend/screens/ObservationEdit/SaveButton.tsx
@@ -166,7 +166,7 @@ export const SaveButton = ({
},
{
text: t(m.manualEntry),
- onPress: () => {},
+ onPress: () => navigation.navigate('ManualGpsScreen'),
style: 'cancel',
},
{
@@ -184,11 +184,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;
};