From cc04704bcabd2804472f0f7c594c31ac2fcb044b Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Mon, 27 Apr 2026 12:54:32 -0400 Subject: [PATCH 1/9] Adds unit conversion choice UI. Adds context and provider. Adds a util for conversion. Uses the conversion in some places. --- messages/en-US/primary.json | 21 ++++ messages/en-US/secondary.json | 3 - src/frontend/App.tsx | 5 +- src/frontend/Navigation/Stack/AppScreens.tsx | 6 ++ src/frontend/contexts/AppProviders.tsx | 102 ++++++++++-------- .../contexts/UnitSystemStoreContext.ts | 77 +++++++++++++ src/frontend/images/AngleRuler.svg | 3 + src/frontend/lib/unitConversion.test.ts | 91 ++++++++++++++++ src/frontend/lib/unitConversion.ts | 36 +++++++ src/frontend/screens/LocationInfoScreen.tsx | 52 ++++++--- src/frontend/screens/ObservationMetadata.tsx | 29 +++-- .../AppSettings/UnitSystemSettings.tsx | 73 +++++++++++++ .../screens/Settings/AppSettings/index.tsx | 14 +++ .../GPSPill/GPSPillUI.test.tsx | 63 ++++++++--- .../sharedComponents/GPSPill/GPSPillUI.tsx | 9 +- .../sharedComponents/GPSPill/index.tsx | 5 +- .../sharedComponents/LocationView.tsx | 17 ++- src/frontend/sharedComponents/TrackStats.tsx | 19 ++-- src/frontend/sharedTypes/navigation.ts | 1 + tests/e2e/specs/settings/index.test.ts | 1 + tests/e2e/specs/settings/unit-system.test.ts | 49 +++++++++ 21 files changed, 569 insertions(+), 107 deletions(-) create mode 100644 src/frontend/contexts/UnitSystemStoreContext.ts create mode 100644 src/frontend/images/AngleRuler.svg create mode 100644 src/frontend/lib/unitConversion.test.ts create mode 100644 src/frontend/lib/unitConversion.ts create mode 100644 src/frontend/screens/Settings/AppSettings/UnitSystemSettings.tsx create mode 100644 tests/e2e/specs/settings/unit-system.test.ts diff --git a/messages/en-US/primary.json b/messages/en-US/primary.json index af8b59cbd2..cd7ac4e294 100644 --- a/messages/en-US/primary.json +++ b/messages/en-US/primary.json @@ -164,6 +164,9 @@ "$1Screens.Settings.AppSettings.title": { "message": "CoMapeo Settings" }, + "$1Screens.Settings.AppSettings.unitSystem": { + "message": "Unit System" + }, "$1TrackBottomSheet.DidNotMove.delete": { "message": "Exit Tracks" }, @@ -888,6 +891,24 @@ "$1screens.TrackCategoryChooser.title": { "message": "Choose a category" }, + "$1screens.UnitSystemSettings.imperial": { + "description": "Label for imperial unit system option", + "message": "Imperial" + }, + "$1screens.UnitSystemSettings.imperialSubtitle": { + "message": "Display Information (ft, mi)" + }, + "$1screens.UnitSystemSettings.metric": { + "description": "Label for metric unit system option", + "message": "Metric" + }, + "$1screens.UnitSystemSettings.metricSubtitle": { + "message": "Display Information (km, m)" + }, + "$1screens.UnitSystemSettings.title": { + "description": "Title for unit system settings screen", + "message": "Unit System" + }, "$1screens.YourTeam.RemoveDevice.removeAndNotifyButton": { "message": "Remove & Notify" }, diff --git a/messages/en-US/secondary.json b/messages/en-US/secondary.json index 3702037747..95a6a03835 100644 --- a/messages/en-US/secondary.json +++ b/messages/en-US/secondary.json @@ -214,9 +214,6 @@ "TrackEdit.HeaderLeft.discardTrackDescription": { "message": "Your changes will not be saved. This cannot be undone." }, - "TrackStats.kilometers": { - "message": "km" - }, "constants.blue": { "message": "Blue" }, diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 63f39631d4..dde6ccc8e4 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -19,6 +19,7 @@ import {createDraftObservationStore} from './contexts/PersistedStores/DraftObser import {createTrackStore} from './contexts/TrackStoreContext'; import {createSecurityStore} from './contexts/SecurityStoreContext'; import {createCoordinateFormatStore} from './contexts/CoordinateFormatStoreContext'; +import {createUnitSystemStore} from './contexts/UnitSystemStoreContext'; import {createManualEntryCoordinateFormatStore} from './contexts/ManualEntryCoordinateFormatStoreContext'; import {createActiveProjectIdStore} from './contexts/ActiveProjectIdStoreContext'; import {createMetricsDiagnosticsStore} from './contexts/MetricsDiagnosticsStoreContext'; @@ -150,6 +151,7 @@ const persistedMetricsDiagnosticsStore = createMetricsDiagnosticsStore({ const savedLocationStore = createSavedLocationStore({persist: true}); const lowStorageBannerStore = createLowStorageBannerStore(); const earlyAccessStore = createEarlyAccessStore({persist: true}); +const persistedUnitSystemStore = createUnitSystemStore({persist: true}); // Ensure that these metrics instances are initially in sync with initial state of relevant store const metricsIsEnabled = @@ -235,7 +237,8 @@ const App = () => { metricsDiagnosticsStore={persistedMetricsDiagnosticsStore} appUsageStatsStore={appUsagePromptStore} lowStorageBannerStore={lowStorageBannerStore} - earlyAccessStore={earlyAccessStore}> + earlyAccessStore={earlyAccessStore} + unitSystemStore={persistedUnitSystemStore}> + { return ( - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/frontend/contexts/UnitSystemStoreContext.ts b/src/frontend/contexts/UnitSystemStoreContext.ts new file mode 100644 index 0000000000..37f015b69f --- /dev/null +++ b/src/frontend/contexts/UnitSystemStoreContext.ts @@ -0,0 +1,77 @@ +import {createContext, useContext} from 'react'; +import {createStore, useStore, type StoreApi} from 'zustand'; +import { + createJSONStorage, + persist as createPersistedState, +} from 'zustand/middleware'; + +import {MMKVStoreInitializer} from '../hooks/persistedState/createPersistedState'; + +export type UnitSystem = 'metric' | 'imperial'; + +type UnitSystemState = { + value: UnitSystem; +}; + +// NOTE: Do not change! +const STORAGE_KEY = 'unit-system' as const; + +function createInitialState(): UnitSystemState { + return { + value: 'metric', + }; +} + +export function createUnitSystemStore({persist} = {persist: false}) { + let store: StoreApi; + + if (persist) { + store = createStore( + createPersistedState(createInitialState, { + name: STORAGE_KEY, + storage: createJSONStorage(() => MMKVStoreInitializer), + version: 0, + }), + ); + } else { + store = createStore(createInitialState); + } + + const actions = { + setUnitSystem: (unitSystem: UnitSystem) => { + store.setState({value: unitSystem}); + }, + }; + + return { + instance: store, + actions, + }; +} + +export type UnitSystemStore = ReturnType; + +export const UnitSystemStoreContext = createContext( + null, +); +export const UnitSystemStoreProvider = UnitSystemStoreContext.Provider; + +function useUnitSystemStoreContext() { + const value = useContext(UnitSystemStoreContext); + + if (!value) { + throw new Error('Must set up the UnitSystemStoreProvider first'); + } + + return value; +} + +export function useUnitSystem(): UnitSystem { + const {instance} = useUnitSystemStoreContext(); + return useStore(instance).value; +} + +export function useUnitSystemActions() { + const {actions} = useUnitSystemStoreContext(); + return actions; +} diff --git a/src/frontend/images/AngleRuler.svg b/src/frontend/images/AngleRuler.svg new file mode 100644 index 0000000000..9f60d1cc11 --- /dev/null +++ b/src/frontend/images/AngleRuler.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/lib/unitConversion.test.ts b/src/frontend/lib/unitConversion.test.ts new file mode 100644 index 0000000000..9b873659ee --- /dev/null +++ b/src/frontend/lib/unitConversion.test.ts @@ -0,0 +1,91 @@ +import { + metersOrConversion, + kmOrConversion, + metersPerSecondOrConversion, +} from './unitConversion'; + +describe('metersOrConversion', () => { + it('returns meters with 0 decimal places in metric', () => { + expect(metersOrConversion(10, 'metric')).toEqual({value: '10', unit: 'm'}); + expect(metersOrConversion(10.75, 'metric')).toEqual({ + value: '11', + unit: 'm', + }); + expect(metersOrConversion(0.4, 'metric')).toEqual({value: '0', unit: 'm'}); + }); + + it('converts to feet in imperial', () => { + // 1m * 3.28084 = 3.28084 → '3' + expect(metersOrConversion(1, 'imperial')).toEqual({value: '3', unit: 'ft'}); + // 10m * 3.28084 = 32.8084 → '33' + expect(metersOrConversion(10, 'imperial')).toEqual({ + value: '33', + unit: 'ft', + }); + // 100m * 3.28084 = 328.084 → '328' + expect(metersOrConversion(100, 'imperial')).toEqual({ + value: '328', + unit: 'ft', + }); + }); +}); + +describe('kmOrConversion', () => { + it('returns km with 2 decimal places in metric', () => { + expect(kmOrConversion(1, 'metric')).toEqual({ + value: '1.00', + unit: 'km', + }); + expect(kmOrConversion(5.678, 'metric')).toEqual({ + value: '5.68', + unit: 'km', + }); + }); + + it('converts to miles in imperial', () => { + // 1km * 0.621371 = 0.621371 → '0.62' + expect(kmOrConversion(1, 'imperial')).toEqual({ + value: '0.62', + unit: 'mi', + }); + // 10km * 0.621371 = 6.21371 → '6.21' + expect(kmOrConversion(10, 'imperial')).toEqual({ + value: '6.21', + unit: 'mi', + }); + expect(kmOrConversion(0, 'imperial')).toEqual({ + value: '0.00', + unit: 'mi', + }); + }); +}); + +describe('metersPerSecondOrConversion', () => { + it('returns m/s with 2 decimal places in metric', () => { + expect(metersPerSecondOrConversion(1, 'metric')).toEqual({ + value: '1.00', + unit: 'm/s', + }); + expect(metersPerSecondOrConversion(9.81, 'metric')).toEqual({ + value: '9.81', + unit: 'm/s', + }); + }); + + it('converts to mph in imperial', () => { + // 1 m/s * 2.23694 = 2.23694 → '2.24' + expect(metersPerSecondOrConversion(1, 'imperial')).toEqual({ + value: '2.24', + unit: 'mph', + }); + // 10 m/s * 2.23694 = 22.3694 → '22.37' + expect(metersPerSecondOrConversion(10, 'imperial')).toEqual({ + value: '22.37', + unit: 'mph', + }); + expect(metersPerSecondOrConversion(0, 'imperial')).toEqual({ + value: '0.00', + unit: 'mph', + }); + }); +}); diff --git a/src/frontend/lib/unitConversion.ts b/src/frontend/lib/unitConversion.ts new file mode 100644 index 0000000000..a32a6ba1ac --- /dev/null +++ b/src/frontend/lib/unitConversion.ts @@ -0,0 +1,36 @@ +import {type UnitSystem} from '../contexts/UnitSystemStoreContext'; + +// --- Conversion factors --- +const M_TO_FT = 3.28084; +const KM_TO_MI = 0.621371; +const MS_TO_MPH = 2.23694; + +export function metersOrConversion( + meters: number, + unitSystem: UnitSystem, +): {value: string; unit: string} { + if (unitSystem === 'imperial') { + return {value: (meters * M_TO_FT).toFixed(0), unit: 'ft'}; + } + return {value: meters.toFixed(0), unit: 'm'}; +} + +export function kmOrConversion( + km: number, + unitSystem: UnitSystem, +): {value: string; unit: string} { + if (unitSystem === 'imperial') { + return {value: (km * KM_TO_MI).toFixed(2), unit: 'mi'}; + } + return {value: km.toFixed(2), unit: 'km'}; +} + +export function metersPerSecondOrConversion( + mps: number, + unitSystem: UnitSystem, +): {value: string; unit: string} { + if (unitSystem === 'imperial') { + return {value: (mps * MS_TO_MPH).toFixed(2), unit: 'mph'}; + } + return {value: mps.toFixed(2), unit: 'm/s'}; +} diff --git a/src/frontend/screens/LocationInfoScreen.tsx b/src/frontend/screens/LocationInfoScreen.tsx index fd97003d19..740807318f 100644 --- a/src/frontend/screens/LocationInfoScreen.tsx +++ b/src/frontend/screens/LocationInfoScreen.tsx @@ -13,9 +13,14 @@ import {GPS_MODAL_TEXT, WHITE} from '../lib/styles'; import {CustomHeaderLeft} from '../sharedComponents/CustomHeaderLeft'; import {DateDistance} from '../sharedComponents/DateDistance'; import {FormattedCoords} from '../sharedComponents/FormattedData'; -import {Text} from '../sharedComponents/Text'; +import {BodyText} from '../sharedComponents/Text/BodyText'; import {useCoordinateFormat} from '../contexts/CoordinateFormatStoreContext'; +import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; import {useLocationState} from '../contexts/LocationContext'; +import { + metersOrConversion, + metersPerSecondOrConversion, +} from '../lib/unitConversion'; const m = defineMessages({ gpsHeader: { @@ -67,8 +72,8 @@ const m = defineMessages({ const InfoRow = ({label, value}: {label: string; value: string}) => ( - {label} - {value} + {label} + {value} ); @@ -77,50 +82,69 @@ export const LocationInfoScreen = () => { const lastKnownLocationQuery = useLastKnownLocation(); const provider = useLocationState(store => store.providerStatus); const coordinateFormat = useCoordinateFormat(); + const unitSystem = useUnitSystem(); const {formatMessage: t} = useIntl(); const locationTimestamp = location?.timestamp || lastKnownLocationQuery.data?.timestamp; + const formatCoordValue = (key: string, value: number): string => { + if ( + key === 'accuracy' || + key === 'altitude' || + key === 'altitudeAccuracy' + ) { + const {value: v, unit} = metersOrConversion(value, unitSystem); + return `${v} ${unit}`; + } + if (key === 'speed') { + const {value: v, unit} = metersPerSecondOrConversion(value, unitSystem); + return `${v} ${unit}`; + } + return value.toFixed(5); + }; + return ( - + - + {location && ( <> - + - - + + - - + + - + {Object.entries(location.coords).map(([key, value]) => ( ))} )} {provider && ( <> - + - + {Object.entries(provider).map(([key, value]) => ( ))} diff --git a/src/frontend/screens/ObservationMetadata.tsx b/src/frontend/screens/ObservationMetadata.tsx index 0e433b23ca..4c086ed31c 100644 --- a/src/frontend/screens/ObservationMetadata.tsx +++ b/src/frontend/screens/ObservationMetadata.tsx @@ -19,6 +19,11 @@ import UnverifiedBadge from '../images/UnverifiedBadge.svg'; import {useProjectSettings} from '@comapeo/core-react'; import {useActiveProject} from '../contexts/ActiveProjectContext'; import {useCoordinateFormat} from '../contexts/CoordinateFormatStoreContext'; +import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; +import { + metersOrConversion, + metersPerSecondOrConversion, +} from '../lib/unitConversion'; import {useOpenShareDialog} from '../hooks/share'; import {useObservationWithPreset} from '../hooks/useObservationWithPreset'; import {formatCoords} from '../lib/coordinateFormat'; @@ -116,8 +121,16 @@ export const ObservationMetadata: NativeNavigationComponent< data: {name}, } = useProjectSettings({projectId}); const coordinateFormat = useCoordinateFormat(); + const unitSystem = useUnitSystem(); const openShare = useOpenShareDialog(); + const manualLocation = metadata?.manualLocation; + const lengthUnit = metersOrConversion(0, unitSystem).unit; + const speedUnit = metersPerSecondOrConversion(0, unitSystem).unit; + const toMetersValue = (raw: number) => + metersOrConversion(raw, unitSystem).value; + const toSpeedValue = (raw: number) => + metersPerSecondOrConversion(raw, unitSystem).value; const listData: { [key: string]: { @@ -160,9 +173,9 @@ export const ObservationMetadata: NativeNavigationComponent< label: formatMessage(m.accuracy), value: metadata?.position?.coords.accuracy != null - ? `± ${Number(metadata.position.coords.accuracy).toFixed(0)}` + ? `± ${toMetersValue(metadata.position.coords.accuracy)}` : undefined, - unit: 'm', + unit: lengthUnit, icon: ( ), diff --git a/src/frontend/screens/Settings/AppSettings/UnitSystemSettings.tsx b/src/frontend/screens/Settings/AppSettings/UnitSystemSettings.tsx new file mode 100644 index 0000000000..f3244dd6ef --- /dev/null +++ b/src/frontend/screens/Settings/AppSettings/UnitSystemSettings.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {ScrollView} from 'react-native'; + +import { + useUnitSystem, + useUnitSystemActions, +} from '../../../contexts/UnitSystemStoreContext'; +import {SelectOne} from '../../../sharedComponents/SelectOne'; +import {type NativeNavigationComponent} from '../../../sharedTypes/navigation'; + +const m = defineMessages({ + title: { + id: '$1screens.UnitSystemSettings.title', + defaultMessage: 'Unit System', + description: 'Title for unit system settings screen', + }, + metric: { + id: '$1screens.UnitSystemSettings.metric', + defaultMessage: 'Metric', + description: 'Label for metric unit system option', + }, + metricSubtitle: { + id: '$1screens.UnitSystemSettings.metricSubtitle', + defaultMessage: 'Display Information (km, m)', + }, + imperial: { + id: '$1screens.UnitSystemSettings.imperial', + defaultMessage: 'Imperial', + description: 'Label for imperial unit system option', + }, + imperialSubtitle: { + id: '$1screens.UnitSystemSettings.imperialSubtitle', + defaultMessage: 'Display Information (ft, mi)', + }, +}); + +export const UnitSystemSettings: NativeNavigationComponent< + 'UnitSystemSettings' +> = () => { + const {formatMessage: t} = useIntl(); + const unitSystem = useUnitSystem(); + const {setUnitSystem} = useUnitSystemActions(); + + const options = [ + { + value: 'metric' as const, + label: t(m.metric), + hint: t(m.metricSubtitle), + }, + { + value: 'imperial' as const, + label: t(m.imperial), + hint: t(m.imperialSubtitle), + }, + ]; + + return ( + + { + setUnitSystem(selected); + }} + /> + + ); +}; + +UnitSystemSettings.navTitle = m.title; diff --git a/src/frontend/screens/Settings/AppSettings/index.tsx b/src/frontend/screens/Settings/AppSettings/index.tsx index 1252c7898b..d7f927d629 100644 --- a/src/frontend/screens/Settings/AppSettings/index.tsx +++ b/src/frontend/screens/Settings/AppSettings/index.tsx @@ -5,6 +5,7 @@ import {useAuthContext} from '../../../contexts/AuthContext'; import {FullScreenMenuList} from '../../../sharedComponents/MenuList/FullScreenMenuList'; import {MenuListItemType} from '../../../sharedComponents/MenuList/MenuListItem'; import BlackShieldIcon from '../../../images/BlackShield.svg'; +import AngleRulerIcon from '../../../images/AngleRuler.svg'; import {useEarlyAccessState} from '../../../contexts/EarlyAccessContext'; const m = defineMessages({ @@ -28,6 +29,10 @@ const m = defineMessages({ id: '$1Screens.Settings.AppSettings.coordinateSystemDesc', defaultMessage: 'UTM,Lat/Lon,DMS', }, + unitSystem: { + id: '$1Screens.Settings.AppSettings.unitSystem', + defaultMessage: 'Unit System', + }, security: { id: '$1Screens.Settings.AppSettings.Drawer.security', defaultMessage: 'Security', @@ -90,6 +95,15 @@ export const AppSettings: NativeNavigationComponent<'AppSettings'> = ({ secondaryText: formatMessage(m.coordinateSystemDesc), materialIconName: 'explore', }, + { + onPress: () => { + navigation.navigate('UnitSystemSettings'); + }, + testID: 'unitSystemButton', + primaryText: formatMessage(m.unitSystem), + icon: , + }, + { onPress: () => { navigation.navigate('DataAndPrivacy'); diff --git a/src/frontend/sharedComponents/GPSPill/GPSPillUI.test.tsx b/src/frontend/sharedComponents/GPSPill/GPSPillUI.test.tsx index a4dffc2766..c6ac9a76cd 100644 --- a/src/frontend/sharedComponents/GPSPill/GPSPillUI.test.tsx +++ b/src/frontend/sharedComponents/GPSPill/GPSPillUI.test.tsx @@ -7,6 +7,7 @@ test('searching status', async () => { render( , @@ -24,7 +25,12 @@ test('searching status', async () => { test('error status', async () => { render( - , + , ); expect(screen.getByText('--')).toBeOnTheScreen(); @@ -38,17 +44,18 @@ test('error status', async () => { }); }); -test('good status', async () => { +test('good status - metric', async () => { render( , ); - expect(screen.getByText('1 ±')).toBeOnTheScreen(); + expect(screen.getByText('±1 m')).toBeOnTheScreen(); expect(screen.getByTestId('gps-pill')).toHaveStyle({ backgroundColor: DARK_GREY, @@ -59,22 +66,44 @@ test('good status', async () => { }); }); -test('displayed accuracy', async () => { - // Handles integers - render(); - expect(screen.getByText('10 ±')).toBeOnTheScreen(); +test('good status - imperial', async () => { + render( + , + ); + + // 1m * 3.28084 = 3.28084, rounds to 3 + expect(screen.getByText('±3 ft')).toBeOnTheScreen(); +}); + +test('displayed accuracy - metric', async () => { + render(); + expect(screen.getByText('±10 m')).toBeOnTheScreen(); + + render(); + expect(screen.getByText('±1 m')).toBeOnTheScreen(); - // Handles negative accuracy elegantly - render(); - expect(screen.getByText('1 ±')).toBeOnTheScreen(); + render(); + expect(screen.getByText('±1 m')).toBeOnTheScreen(); - // Handles floats - render(); - expect(screen.getByText('1 ±')).toBeOnTheScreen(); + render(); + expect(screen.getByText('±5 m')).toBeOnTheScreen(); + + render(); + expect(screen.getByText('±11 m')).toBeOnTheScreen(); +}); - render(); - expect(screen.getByText('5 ±')).toBeOnTheScreen(); +test('displayed accuracy - imperial', async () => { + // 10m * 3.28084 = 32.8084, rounds to 33 + render(); + expect(screen.getByText('±33 ft')).toBeOnTheScreen(); - render(); - expect(screen.getByText('11 ±')).toBeOnTheScreen(); + // 5m * 3.28084 = 16.4042, rounds to 16 + render(); + expect(screen.getByText('±16 ft')).toBeOnTheScreen(); }); diff --git a/src/frontend/sharedComponents/GPSPill/GPSPillUI.tsx b/src/frontend/sharedComponents/GPSPill/GPSPillUI.tsx index 1fb8d0cf3b..a651a6edff 100644 --- a/src/frontend/sharedComponents/GPSPill/GPSPillUI.tsx +++ b/src/frontend/sharedComponents/GPSPill/GPSPillUI.tsx @@ -2,6 +2,7 @@ import {type ReactNode} from 'react'; import {TouchableOpacity, View} from 'react-native'; import {UIActivityIndicator} from 'react-native-indicators'; import MaterialCommunityIcon from '@react-native-vector-icons/material-design-icons'; +import {type UnitSystem} from '../../contexts/UnitSystemStoreContext'; import {ExhaustivenessError} from '../../lib/ExhaustivenessError'; import { @@ -18,6 +19,7 @@ type Props = { onPress?: () => void; iconTestID?: string; testID?: string; + unitSystem: UnitSystem; } & ( | { status: 'searching' | 'error'; @@ -78,7 +80,12 @@ export const GPSPillUI = (props: Props) => { case 'good': { backgroundColor = DARK_GREY; - text = `${Math.abs(Math.round(props.accuracy))} ±`; + const unit = props.unitSystem === 'imperial' ? 'ft' : 'm'; + const accuracyValue = + props.unitSystem === 'imperial' + ? Math.abs(Math.round(props.accuracy * 3.28084)) + : Math.abs(Math.round(props.accuracy)); + text = `±${accuracyValue} ${unit}`; icon = ( void}) => { const locationProviderStatus = useLocationState( store => store.providerStatus, ); - const location = useLocationState(store => store.location); + const unitSystem = useUnitSystem(); + return ( { const coordinateFormat = useCoordinateFormat(); + const unitSystem = useUnitSystem(); + return ( {lat === undefined || lon === undefined ? ( @@ -34,11 +38,14 @@ export const LocationView = ({lat, lon, accuracy}: LocationViewProps) => { - {accuracy === undefined ? null : ( - - {' ±' + accuracy.toFixed(2) + 'm'} - - )} + {accuracy === undefined + ? null + : (() => { + const {value, unit} = metersOrConversion(accuracy, unitSystem); + return ( + {` ±${value} ${unit}`} + ); + })()} )} diff --git a/src/frontend/sharedComponents/TrackStats.tsx b/src/frontend/sharedComponents/TrackStats.tsx index 3d4384f6a4..188a5bd50d 100644 --- a/src/frontend/sharedComponents/TrackStats.tsx +++ b/src/frontend/sharedComponents/TrackStats.tsx @@ -4,14 +4,8 @@ import SimpleTrackIcon from '../images/SimpleTrack.svg'; import {millisecondsToHHMMSS} from '../lib/millisecondsToFormattedTime'; import {BLUE_GREY} from '../lib/styles'; import {BodyText} from './Text/BodyText'; -import {defineMessages, useIntl} from 'react-intl'; - -const m = defineMessages({ - kilometers: { - id: 'TrackStats.kilometers', - defaultMessage: 'km', - }, -}); +import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; +import {kmOrConversion} from '../lib/unitConversion'; type TrackStatsProps = { distance: number; @@ -26,9 +20,12 @@ export const TrackStats = ({ backgroundColor, center = false, }: TrackStatsProps) => { - const {formatMessage} = useIntl(); + const unitSystem = useUnitSystem(); const totalTime = millisecondsToHHMMSS(durationMs); - const totalKm = distance.toFixed(2); + const {value: displayDistance, unit: distanceUnit} = kmOrConversion( + distance, + unitSystem, + ); return ( - {totalKm} {formatMessage(m.kilometers)} + {displayDistance} {distanceUnit} diff --git a/src/frontend/sharedTypes/navigation.ts b/src/frontend/sharedTypes/navigation.ts index 7756f0b57e..920c2453ec 100644 --- a/src/frontend/sharedTypes/navigation.ts +++ b/src/frontend/sharedTypes/navigation.ts @@ -57,6 +57,7 @@ export type RootStackParamsList = { AboutSettings: undefined; LanguageSettings: undefined; CoordinateFormat: undefined; + UnitSystemSettings: undefined; DraftPhotoPreviewModal: { photoMetadata: PhotoMetadata; photoExif?: PhotoEXIF; diff --git a/tests/e2e/specs/settings/index.test.ts b/tests/e2e/specs/settings/index.test.ts index 63b3d1fcb3..ab9640227e 100644 --- a/tests/e2e/specs/settings/index.test.ts +++ b/tests/e2e/specs/settings/index.test.ts @@ -4,6 +4,7 @@ describe('Settings', function () { require('../onboarding/helper/minimal-onboarding-setup.test'); require('./edit-device-name.test'); require('./coordinates.test'); + require('./unit-system.test'); require('./language.test'); require('./about-comapeo.test'); require('./early-access.test'); diff --git a/tests/e2e/specs/settings/unit-system.test.ts b/tests/e2e/specs/settings/unit-system.test.ts new file mode 100644 index 0000000000..a29057532c --- /dev/null +++ b/tests/e2e/specs/settings/unit-system.test.ts @@ -0,0 +1,49 @@ +import {expect} from '@wdio/globals'; +import {describe, it} from 'mocha'; +import {byResourceId, byTextMatches} from '../../utils/selectors'; + +describe('Settings - Unit System Flow', () => { + it('should open the App Settings from the drawer', async () => { + const drawerIcon = await $('~Open Menu'); + await drawerIcon.click(); + + const appSettingsOption = await $('~Go to app settings screen.'); + await appSettingsOption.click(); + }); + + it('should open Unit System screen', async () => { + const unitSystemOption = await $(byTextMatches('Unit System')); + await unitSystemOption.click(); + + await expect($(byTextMatches('Metric'))).toBeDisplayed(); + await expect($(byTextMatches('Imperial'))).toBeDisplayed(); + }); + + it('should select Imperial and return to settings', async () => { + const imperialOption = await $(byTextMatches('Imperial')); + await imperialOption.click(); + + await expect($(byTextMatches('CoMapeo Settings'))).toBeDisplayed(); + }); + + it('should re-open Unit System and switch back to Metric', async () => { + const unitSystemOption = await $(byTextMatches('Unit System')); + await unitSystemOption.click(); + + const imperialButton = await $(byResourceId('imperialButton-selected')); + await expect(imperialButton).toBeDisplayed(); + + const metricOption = await $(byTextMatches('Metric')); + await metricOption.click(); + + await expect($(byTextMatches('CoMapeo Settings'))).toBeDisplayed(); + }); + + it('should navigate back to the map', async () => { + const backBtn = await $(byResourceId('MAIN.header-back-btn')); + await backBtn.click(); + + await $(byResourceId('MAIN.map-screen')).click(); + await expect($(byResourceId('MAIN.mapbox-map-view'))).toBeDisplayed(); + }); +}); From c3294b60bc591fde894c76485e96d617e9b9431b Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Mon, 27 Apr 2026 15:11:40 -0400 Subject: [PATCH 2/9] Gets rid of deprecated text component. --- src/frontend/screens/LocationInfoScreen.tsx | 25 +++++++++---------- .../sharedComponents/LocationView.tsx | 17 +++++++------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/frontend/screens/LocationInfoScreen.tsx b/src/frontend/screens/LocationInfoScreen.tsx index 740807318f..0157e0de7d 100644 --- a/src/frontend/screens/LocationInfoScreen.tsx +++ b/src/frontend/screens/LocationInfoScreen.tsx @@ -14,6 +14,7 @@ import {CustomHeaderLeft} from '../sharedComponents/CustomHeaderLeft'; import {DateDistance} from '../sharedComponents/DateDistance'; import {FormattedCoords} from '../sharedComponents/FormattedData'; import {BodyText} from '../sharedComponents/Text/BodyText'; +import {HeaderText} from '../sharedComponents/Text/HeaderText'; import {useCoordinateFormat} from '../contexts/CoordinateFormatStoreContext'; import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; import {useLocationState} from '../contexts/LocationContext'; @@ -72,7 +73,9 @@ const m = defineMessages({ const InfoRow = ({label, value}: {label: string; value: string}) => ( - {label} + + {label} + {value} ); @@ -107,18 +110,18 @@ export const LocationInfoScreen = () => { return ( - + - + {location && ( <> - + - + { format={coordinateFormat} /> - + - + {Object.entries(location.coords).map(([key, value]) => ( { )} {provider && ( <> - + - + {Object.entries(provider).map(([key, value]) => ( ))} @@ -182,19 +185,15 @@ const styles = StyleSheet.create({ row: {flexDirection: 'row'}, sectionTitle: { color: 'white', - fontWeight: '700', marginTop: 10, marginBottom: 5, - fontSize: 16, }, rowLabel: { color: 'white', - fontWeight: '700', minWidth: '50%', }, rowValue: { color: 'white', - fontWeight: '400', }, infoArea: { paddingLeft: 15, diff --git a/src/frontend/sharedComponents/LocationView.tsx b/src/frontend/sharedComponents/LocationView.tsx index 18adf0a6ac..f6484496ac 100644 --- a/src/frontend/sharedComponents/LocationView.tsx +++ b/src/frontend/sharedComponents/LocationView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {View, Text, StyleSheet} from 'react-native'; +import {View, StyleSheet} from 'react-native'; import {BLACK} from '../lib/styles'; import {FormattedMessage, defineMessages} from 'react-intl'; import LocationIcon from '../images/Location.svg'; @@ -7,6 +7,7 @@ import {FormattedCoords} from './FormattedData'; import {useCoordinateFormat} from '../contexts/CoordinateFormatStoreContext'; import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; import {metersOrConversion} from '../lib/unitConversion'; +import {BodyText} from './Text/BodyText'; const m = defineMessages({ searching: { @@ -29,21 +30,23 @@ export const LocationView = ({lat, lon, accuracy}: LocationViewProps) => { return ( {lat === undefined || lon === undefined ? ( - + - + ) : ( - + - + {accuracy === undefined ? null : (() => { const {value, unit} = metersOrConversion(accuracy, unitSystem); return ( - {` ±${value} ${unit}`} + {` ±${value} ${unit}`} ); })()} @@ -62,11 +65,9 @@ const styles = StyleSheet.create({ }, locationText: { color: BLACK, - fontSize: 12, flex: 1, }, accuracy: { color: BLACK, - fontSize: 12, }, }); From afe57a6697e86d096f324d6905b87806e9847e0a Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Mon, 27 Apr 2026 15:40:06 -0400 Subject: [PATCH 3/9] Adds more conversions as needed. --- messages/en-US/secondary.json | 4 ++-- .../BackgroundMaps/MapReceivedBottomSheet.tsx | 21 +++++++++++++------ .../CurrentTrack/UserTooltipMarker.tsx | 11 +++++++++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/messages/en-US/secondary.json b/messages/en-US/secondary.json index 95a6a03835..04a4a0dc18 100644 --- a/messages/en-US/secondary.json +++ b/messages/en-US/secondary.json @@ -1181,8 +1181,8 @@ "screens.Settings.MapManagement.MapReceived.decline": { "message": "Decline" }, - "screens.Settings.MapManagement.MapReceived.kmAway": { - "message": "{distance} km away" + "screens.Settings.MapManagement.MapReceived.distanceAway": { + "message": "{distance} away" }, "screens.Settings.MapManagement.MapReceived.locationNotCovered": { "message": "Current location not covered!" diff --git a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx index 8bf6295a8a..c984d10100 100644 --- a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx +++ b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx @@ -31,6 +31,8 @@ import { getErrorCode, MapShareErrorCode, } from '@comapeo/core-react'; +import {useUnitSystem} from '../../contexts/UnitSystemStoreContext'; +import {kmOrConversion} from '../../lib/unitConversion'; import * as Sentry from '@sentry/react-native'; import {toError} from '../../utils/errors'; @@ -51,9 +53,9 @@ const m = defineMessages({ id: 'screens.Settings.MapManagement.MapReceived.locationNotCovered', defaultMessage: 'Current location not covered!', }, - kmAway: { - id: 'screens.Settings.MapManagement.MapReceived.kmAway', - defaultMessage: '{distance} km away', + distanceAway: { + id: 'screens.Settings.MapManagement.MapReceived.distanceAway', + defaultMessage: '{distance} away', }, notEnoughSpace: { id: 'screens.Settings.MapManagement.MapReceived.notEnoughSpace', @@ -74,6 +76,7 @@ export function MapReceivedBottomSheet({ navigation, }: NativeRootNavigationProps<'MapReceivedBottomSheet'>) { const {formatMessage: t} = useIntl(); + const unitSystem = useUnitSystem(); const {shareId} = route.params; const mapShare = useSingleReceivedMapShare({shareId}); @@ -222,9 +225,15 @@ export function MapReceivedBottomSheet({ {warningInfo.warning === 'location' - ? t(m.kmAway, { - distance: warningInfo.distanceKm?.toFixed(1), - }) + ? (() => { + const {value, unit} = kmOrConversion( + warningInfo.distanceKm ?? 0, + unitSystem, + ); + return t(m.distanceAway, { + distance: `${value} ${unit}`, + }); + })() : t(m.mbNeeded, {size: warningInfo.mbNeeded})} diff --git a/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx b/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx index 043050d2f9..b0e2a8bccc 100644 --- a/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx +++ b/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx @@ -4,11 +4,18 @@ import {StyleSheet, Text, View} from 'react-native'; import {useTrackState} from '../../../contexts/TrackStoreContext'; import {useLocationState} from '../../../contexts/LocationContext'; import {useTrackTimer} from '../../../hooks/useTrackTimer.ts'; +import {useUnitSystem} from '../../../contexts/UnitSystemStoreContext'; +import {kmOrConversion} from '../../../lib/unitConversion'; export const UserTooltipMarker = () => { const timer = useTrackTimer(); const location = useLocationState(store => store.location); const totalDistance = useTrackState(state => state.distance); + const unitSystem = useUnitSystem(); + const {value: distanceValue, unit: distanceUnit} = kmOrConversion( + totalDistance, + unitSystem, + ); return ( // We dont want to put this check in the parent because it will cause the parent (the map) to render too often @@ -20,7 +27,9 @@ export const UserTooltipMarker = () => { - {totalDistance.toFixed(2)}km + + {distanceValue} {distanceUnit} + From d03287f2ac28259a1e2c8ee67bc5f1aa1826bd22 Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Mon, 27 Apr 2026 18:39:07 -0400 Subject: [PATCH 4/9] More uses of distance that need to be converted. --- src/frontend/lib/unitConversion.test.ts | 12 +++++++ src/frontend/lib/unitConversion.ts | 5 +-- src/frontend/screens/Observation/Buttons.tsx | 11 +++++- .../screens/Observation/InsetMapView.tsx | 13 ++++++- .../AttachedPhotoPreviewModal.tsx | 35 +++++++++++-------- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/frontend/lib/unitConversion.test.ts b/src/frontend/lib/unitConversion.test.ts index 9b873659ee..b9ec3eaf6a 100644 --- a/src/frontend/lib/unitConversion.test.ts +++ b/src/frontend/lib/unitConversion.test.ts @@ -28,6 +28,18 @@ describe('metersOrConversion', () => { unit: 'ft', }); }); + + it('respects the decimals parameter', () => { + expect(metersOrConversion(10.75, 'metric', 2)).toEqual({ + value: '10.75', + unit: 'm', + }); + // 1m * 3.28084 = 3.28084 → '3.28' + expect(metersOrConversion(1, 'imperial', 2)).toEqual({ + value: '3.28', + unit: 'ft', + }); + }); }); describe('kmOrConversion', () => { diff --git a/src/frontend/lib/unitConversion.ts b/src/frontend/lib/unitConversion.ts index a32a6ba1ac..1fef8ea987 100644 --- a/src/frontend/lib/unitConversion.ts +++ b/src/frontend/lib/unitConversion.ts @@ -8,11 +8,12 @@ const MS_TO_MPH = 2.23694; export function metersOrConversion( meters: number, unitSystem: UnitSystem, + decimals = 0, ): {value: string; unit: string} { if (unitSystem === 'imperial') { - return {value: (meters * M_TO_FT).toFixed(0), unit: 'ft'}; + return {value: (meters * M_TO_FT).toFixed(decimals), unit: 'ft'}; } - return {value: meters.toFixed(0), unit: 'm'}; + return {value: meters.toFixed(decimals), unit: 'm'}; } export function kmOrConversion( diff --git a/src/frontend/screens/Observation/Buttons.tsx b/src/frontend/screens/Observation/Buttons.tsx index 8c12b9d1fb..4e2e007e57 100644 --- a/src/frontend/screens/Observation/Buttons.tsx +++ b/src/frontend/screens/Observation/Buttons.tsx @@ -17,6 +17,8 @@ import {BodyText} from '../../sharedComponents/Text/BodyText.tsx'; import {isSavedPhoto} from '../../lib/attachmentTypeChecks.ts'; import {useOpenShareDialog} from '../../hooks/share.ts'; import {useCoordinateFormat} from '../../contexts/CoordinateFormatStoreContext.ts'; +import {useUnitSystem} from '../../contexts/UnitSystemStoreContext'; +import {metersOrConversion} from '../../lib/unitConversion'; import {useCanEditOrDelete} from '../../hooks/server/useCanEditOrDelete.ts'; const m = defineMessages({ delete: { @@ -111,6 +113,7 @@ export const ButtonFields = ({ const navigation = useNavigationFromRoot(); const {observation, preset} = useObservationWithPreset(observationId); const coordinateFormat = useCoordinateFormat(); + const unitSystem = useUnitSystem(); const [isShareButtonLoading, setShareButtonLoading] = useState(false); const {projectApi, projectId} = useActiveProject(); const { @@ -214,7 +217,13 @@ export const ButtonFields = ({ : ''; const precision = observation.metadata?.position?.coords?.accuracy - ? `${t(m.precision)} ${observation.metadata.position.coords.accuracy}m` + ? (() => { + const {value, unit} = metersOrConversion( + observation.metadata.position.coords.accuracy, + unitSystem, + ); + return `${t(m.precision)} ${value} ${unit}`; + })() : ''; const displayedFields = completedFields diff --git a/src/frontend/screens/Observation/InsetMapView.tsx b/src/frontend/screens/Observation/InsetMapView.tsx index 44004fbf02..60bec42b5c 100644 --- a/src/frontend/screens/Observation/InsetMapView.tsx +++ b/src/frontend/screens/Observation/InsetMapView.tsx @@ -10,6 +10,8 @@ import OrangeDot from '../../images/OrangeDot.svg'; import {MarkerView} from '@rnmapbox/maps'; import {BodyText} from '../../sharedComponents/Text/BodyText'; import {useCoordinateFormat} from '../../contexts/CoordinateFormatStoreContext'; +import {useUnitSystem} from '../../contexts/UnitSystemStoreContext'; +import {metersOrConversion} from '../../lib/unitConversion'; const MAP_HEIGHT = 175; @@ -23,6 +25,7 @@ type MapProps = { export const InsetMapView = React.memo( ({lon, lat, observationId, accuracy}: MapProps) => { const coordinateFormat = useCoordinateFormat(); + const unitSystem = useUnitSystem(); const styleUrlQuery = useMapStyleJsonUrl(); const {navigate} = useNavigationFromRoot(); @@ -57,7 +60,15 @@ export const InsetMapView = React.memo( lat={lat} lon={lon} /> - {accuracy && ` ± ${accuracy.toFixed(2)} m`} + {accuracy && + (() => { + const {value, unit} = metersOrConversion( + accuracy, + unitSystem, + 2, + ); + return ` ± ${value} ${unit}`; + })()} diff --git a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx index 9f61eeb491..5ea3e40c56 100644 --- a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx +++ b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx @@ -24,6 +24,8 @@ import { import {CoreBlobImage} from '../../sharedComponents/Images/CoreBlobImage.tsx'; import {ImageErrorPlaceholder} from '../../sharedComponents/Images/ImageErrorPlaceholder.tsx'; import {useActiveProject} from '../../contexts/ActiveProjectContext.tsx'; +import {useUnitSystem} from '../../contexts/UnitSystemStoreContext'; +import {kmOrConversion} from '../../lib/unitConversion'; import MaterialIcons from '@react-native-vector-icons/material-icons'; import {BodyText} from '../../sharedComponents/Text/BodyText.tsx'; import {sharedStyles} from './sharedStyles.ts'; @@ -94,6 +96,7 @@ export function AttachedPhotoPreviewModal({ const {data: memberInfo} = useGetCreatedBy(observationOriginalVersionId); const {formatMessage, formatNumber} = useIntl(); + const unitSystem = useUnitSystem(); const photoTimeRelativeToObs = photoCreatedAt ? calcPhotoTimeRelativeToObs({ @@ -225,24 +228,28 @@ export function AttachedPhotoPreviewModal({ /> }> - {metersFromObservation < 1000 - ? formatMessage(m.distanceFromObs, { - distance: formatNumber(metersFromObservation, { - style: 'unit', - unit: 'meter', - maximumFractionDigits: 2, - }), - }) - : formatMessage(m.distanceFromObs, { - distance: formatNumber( + {formatMessage(m.distanceFromObs, { + distance: (() => { + if (unitSystem === 'imperial') { + const {value, unit} = kmOrConversion( metersFromObservation / 1000, - { + unitSystem, + ); + return `${value} ${unit}`; + } + return metersFromObservation < 1000 + ? formatNumber(metersFromObservation, { + style: 'unit', + unit: 'meter', + maximumFractionDigits: 2, + }) + : formatNumber(metersFromObservation / 1000, { style: 'unit', unit: 'kilometer', maximumFractionDigits: 2, - }, - ), - })} + }); + })(), + })} )} From 0e0363fd6bf4b454fd56f06b76a05fef9087885d Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Mon, 27 Apr 2026 18:58:09 -0400 Subject: [PATCH 5/9] Adds unit system store to helper for test. --- tests/integration/helpers/react.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/helpers/react.tsx b/tests/integration/helpers/react.tsx index 4910cd8587..2f7993b9d4 100644 --- a/tests/integration/helpers/react.tsx +++ b/tests/integration/helpers/react.tsx @@ -28,6 +28,7 @@ import {createSavedLocationStore} from '../../../src/frontend/contexts/SavedLoca import {createLowStorageBannerStore} from '../../../src/frontend/contexts/LowStorageBannerContext'; import {createEarlyAccessStore} from '../../../src/frontend/contexts/EarlyAccessContext'; import {createAppUsageStatsStore} from '../../../src/frontend/contexts/AppUsageStatsContext'; +import {createUnitSystemStore} from '../../../src/frontend/contexts/UnitSystemStoreContext'; const DEFAULT_LOCAL_DISCOVERY_STATE: LocalDiscoveryState = { status: 'started', @@ -166,6 +167,8 @@ export function createAppProvidersWrapper({ const persistedEarlyAccessStore = createEarlyAccessStore({persist: false}); + const unitSystemStore = createUnitSystemStore({persist: false}); + const lowStorageBannerStore = createLowStorageBannerStore(); const appUsagePromptStore = createAppUsageStatsStore({ @@ -207,7 +210,8 @@ export function createAppProvidersWrapper({ trackStore={persistedTrackStore} lowStorageBannerStore={lowStorageBannerStore} appUsageStatsStore={appUsagePromptStore} - earlyAccessStore={persistedEarlyAccessStore}> + earlyAccessStore={persistedEarlyAccessStore} + unitSystemStore={unitSystemStore}> {children} From bf40cc53a4e06a90946bc9243682ebf25c2c2a4e Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Tue, 28 Apr 2026 10:39:15 -0400 Subject: [PATCH 6/9] Enhances E2E test to account for checking for imperial or metric. --- tests/e2e/specs/settings/unit-system.test.ts | 42 ++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/e2e/specs/settings/unit-system.test.ts b/tests/e2e/specs/settings/unit-system.test.ts index a29057532c..c7163001cf 100644 --- a/tests/e2e/specs/settings/unit-system.test.ts +++ b/tests/e2e/specs/settings/unit-system.test.ts @@ -22,11 +22,37 @@ describe('Settings - Unit System Flow', () => { it('should select Imperial and return to settings', async () => { const imperialOption = await $(byTextMatches('Imperial')); await imperialOption.click(); + const backBtn = await $(byResourceId('MAIN.header-back-btn')); + await backBtn.click(); await expect($(byTextMatches('CoMapeo Settings'))).toBeDisplayed(); }); - it('should re-open Unit System and switch back to Metric', async () => { + it('should display feet abbreviation on the GPS pill on the map', async () => { + const backBtn = await $(byResourceId('MAIN.header-back-btn')); + await backBtn.click(); + await $(byResourceId('MAIN.map-screen')).click(); + await expect($(byResourceId('MAIN.mapbox-map-view'))).toBeDisplayed(); + await expect($(byTextMatches('±\\d+ ft'))).toBeDisplayed(); + }); + + it('should display imperial units within the GPS information screen', async () => { + await $(byResourceId('MAP.gps-pill')).click(); + await expect($(byResourceId('MAIN.gps-details-scrn'))).toBeDisplayed(); + await expect($(byTextMatches('\\d+(\\.\\d+)? ft'))).toBeDisplayed(); + await expect($(byTextMatches('\\d+\\.\\d+ mph'))).toBeDisplayed(); + }); + + it('should switch back to Metric via settings', async () => { + const backBtn = await $(byResourceId('MAIN.header-back-btn')); + await backBtn.click(); + + const drawerIcon = await $('~Open Menu'); + await drawerIcon.click(); + + const appSettingsOption = await $('~Go to app settings screen.'); + await appSettingsOption.click(); + const unitSystemOption = await $(byTextMatches('Unit System')); await unitSystemOption.click(); @@ -35,15 +61,25 @@ describe('Settings - Unit System Flow', () => { const metricOption = await $(byTextMatches('Metric')); await metricOption.click(); - + await $(byResourceId('MAIN.header-back-btn')).click(); await expect($(byTextMatches('CoMapeo Settings'))).toBeDisplayed(); }); - it('should navigate back to the map', async () => { + it('should display meters on the GPS pill after switching to Metric', async () => { const backBtn = await $(byResourceId('MAIN.header-back-btn')); await backBtn.click(); await $(byResourceId('MAIN.map-screen')).click(); await expect($(byResourceId('MAIN.mapbox-map-view'))).toBeDisplayed(); + await expect($(byTextMatches('±\\d+ m'))).toBeDisplayed(); + }); + + it('should display metric units within the GPS information screen', async () => { + await $(byResourceId('MAP.gps-pill')).click(); + await expect($(byResourceId('MAIN.gps-details-scrn'))).toBeDisplayed(); + await expect($(byTextMatches('\\d+(\\.\\d+)? m'))).toBeDisplayed(); + await expect($(byTextMatches('\\d+\\.\\d+ m/s'))).toBeDisplayed(); + const backBtn = await $(byResourceId('MAIN.header-back-btn')); + await backBtn.click(); }); }); From 62701f6fc5fc5d7152d10f3a8a4894dc413c2c05 Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Wed, 6 May 2026 18:14:36 -0400 Subject: [PATCH 7/9] Strong unit types. Conversions return raw numbers. Speed is ft per second now. No select one component. Replaces context.provider. Helpers for getting units. Call sites format numbers. --- .../contexts/UnitSystemStoreContext.ts | 13 +++- src/frontend/lib/unitConversion.test.ts | 77 +++++++------------ src/frontend/lib/unitConversion.ts | 37 ++++++--- .../BackgroundMaps/MapReceivedBottomSheet.tsx | 2 +- .../ComapeoSettings/UnitSystemSettings.tsx | 53 ++++++++----- .../screens/ComapeoSettings/index.tsx | 2 +- src/frontend/screens/LocationInfoScreen.tsx | 4 +- .../CurrentTrack/UserTooltipMarker.tsx | 3 +- src/frontend/screens/Observation/Buttons.tsx | 2 +- .../screens/Observation/InsetMapView.tsx | 3 +- src/frontend/screens/ObservationMetadata.tsx | 10 ++- .../AttachedPhotoPreviewModal.tsx | 2 +- .../sharedComponents/LocationView.tsx | 4 +- src/frontend/sharedComponents/TrackStats.tsx | 2 +- tests/e2e/specs/settings/unit-system.test.ts | 2 +- 15 files changed, 116 insertions(+), 100 deletions(-) diff --git a/src/frontend/contexts/UnitSystemStoreContext.ts b/src/frontend/contexts/UnitSystemStoreContext.ts index 37f015b69f..ff5aaa0c3f 100644 --- a/src/frontend/contexts/UnitSystemStoreContext.ts +++ b/src/frontend/contexts/UnitSystemStoreContext.ts @@ -1,4 +1,4 @@ -import {createContext, useContext} from 'react'; +import React, {createContext, useContext} from 'react'; import {createStore, useStore, type StoreApi} from 'zustand'; import { createJSONStorage, @@ -54,7 +54,16 @@ export type UnitSystemStore = ReturnType; export const UnitSystemStoreContext = createContext( null, ); -export const UnitSystemStoreProvider = UnitSystemStoreContext.Provider; + +export function UnitSystemStoreProvider({ + value, + children, +}: { + value: UnitSystemStore; + children: React.ReactNode; +}) { + return React.createElement(UnitSystemStoreContext, {value}, children); +} function useUnitSystemStoreContext() { const value = useContext(UnitSystemStoreContext); diff --git a/src/frontend/lib/unitConversion.test.ts b/src/frontend/lib/unitConversion.test.ts index b9ec3eaf6a..d35b8a5d0e 100644 --- a/src/frontend/lib/unitConversion.test.ts +++ b/src/frontend/lib/unitConversion.test.ts @@ -5,99 +5,74 @@ import { } from './unitConversion'; describe('metersOrConversion', () => { - it('returns meters with 0 decimal places in metric', () => { - expect(metersOrConversion(10, 'metric')).toEqual({value: '10', unit: 'm'}); + it('returns meters in metric', () => { + expect(metersOrConversion(10, 'metric')).toEqual({value: 10, unit: 'm'}); expect(metersOrConversion(10.75, 'metric')).toEqual({ - value: '11', + value: 10.75, unit: 'm', }); - expect(metersOrConversion(0.4, 'metric')).toEqual({value: '0', unit: 'm'}); + expect(metersOrConversion(0.4, 'metric')).toEqual({value: 0.4, unit: 'm'}); }); it('converts to feet in imperial', () => { - // 1m * 3.28084 = 3.28084 → '3' - expect(metersOrConversion(1, 'imperial')).toEqual({value: '3', unit: 'ft'}); - // 10m * 3.28084 = 32.8084 → '33' - expect(metersOrConversion(10, 'imperial')).toEqual({ - value: '33', + expect(metersOrConversion(1, 'imperial')).toEqual({ + value: 3.28084, unit: 'ft', }); - // 100m * 3.28084 = 328.084 → '328' - expect(metersOrConversion(100, 'imperial')).toEqual({ - value: '328', + expect(metersOrConversion(10, 'imperial')).toEqual({ + value: 32.8084, unit: 'ft', }); - }); - - it('respects the decimals parameter', () => { - expect(metersOrConversion(10.75, 'metric', 2)).toEqual({ - value: '10.75', - unit: 'm', - }); - // 1m * 3.28084 = 3.28084 → '3.28' - expect(metersOrConversion(1, 'imperial', 2)).toEqual({ - value: '3.28', + expect(metersOrConversion(100, 'imperial')).toEqual({ + value: 328.084, unit: 'ft', }); }); }); describe('kmOrConversion', () => { - it('returns km with 2 decimal places in metric', () => { - expect(kmOrConversion(1, 'metric')).toEqual({ - value: '1.00', - unit: 'km', - }); - expect(kmOrConversion(5.678, 'metric')).toEqual({ - value: '5.68', - unit: 'km', - }); + it('returns km in metric', () => { + expect(kmOrConversion(1, 'metric')).toEqual({value: 1, unit: 'km'}); + expect(kmOrConversion(5.678, 'metric')).toEqual({value: 5.678, unit: 'km'}); }); it('converts to miles in imperial', () => { - // 1km * 0.621371 = 0.621371 → '0.62' expect(kmOrConversion(1, 'imperial')).toEqual({ - value: '0.62', + value: 0.621371, unit: 'mi', }); - // 10km * 0.621371 = 6.21371 → '6.21' expect(kmOrConversion(10, 'imperial')).toEqual({ - value: '6.21', - unit: 'mi', - }); - expect(kmOrConversion(0, 'imperial')).toEqual({ - value: '0.00', + value: 6.21371, unit: 'mi', }); + expect(kmOrConversion(0, 'imperial')).toEqual({value: 0, unit: 'mi'}); }); }); describe('metersPerSecondOrConversion', () => { - it('returns m/s with 2 decimal places in metric', () => { + it('returns m/s in metric', () => { expect(metersPerSecondOrConversion(1, 'metric')).toEqual({ - value: '1.00', + value: 1, unit: 'm/s', }); expect(metersPerSecondOrConversion(9.81, 'metric')).toEqual({ - value: '9.81', + value: 9.81, unit: 'm/s', }); }); - it('converts to mph in imperial', () => { - // 1 m/s * 2.23694 = 2.23694 → '2.24' + it('converts to ft/s in imperial', () => { expect(metersPerSecondOrConversion(1, 'imperial')).toEqual({ - value: '2.24', - unit: 'mph', + value: 3.28084, + unit: 'ft/s', }); - // 10 m/s * 2.23694 = 22.3694 → '22.37' expect(metersPerSecondOrConversion(10, 'imperial')).toEqual({ - value: '22.37', - unit: 'mph', + value: 32.8084, + unit: 'ft/s', }); expect(metersPerSecondOrConversion(0, 'imperial')).toEqual({ - value: '0.00', - unit: 'mph', + value: 0, + unit: 'ft/s', }); }); }); diff --git a/src/frontend/lib/unitConversion.ts b/src/frontend/lib/unitConversion.ts index 1fef8ea987..07521e9457 100644 --- a/src/frontend/lib/unitConversion.ts +++ b/src/frontend/lib/unitConversion.ts @@ -3,35 +3,50 @@ import {type UnitSystem} from '../contexts/UnitSystemStoreContext'; // --- Conversion factors --- const M_TO_FT = 3.28084; const KM_TO_MI = 0.621371; -const MS_TO_MPH = 2.23694; +const MS_TO_FTS = 3.28084; + +export type LengthUnit = 'ft' | 'm'; +export type DistanceUnit = 'mi' | 'km'; +export type SpeedUnit = 'ft/s' | 'm/s'; + +export function getLengthUnit(unitSystem: UnitSystem): LengthUnit { + return unitSystem === 'imperial' ? 'ft' : 'm'; +} + +export function getDistanceUnit(unitSystem: UnitSystem): DistanceUnit { + return unitSystem === 'imperial' ? 'mi' : 'km'; +} + +export function getSpeedUnit(unitSystem: UnitSystem): SpeedUnit { + return unitSystem === 'imperial' ? 'ft/s' : 'm/s'; +} export function metersOrConversion( meters: number, unitSystem: UnitSystem, - decimals = 0, -): {value: string; unit: string} { +): {value: number; unit: LengthUnit} { if (unitSystem === 'imperial') { - return {value: (meters * M_TO_FT).toFixed(decimals), unit: 'ft'}; + return {value: meters * M_TO_FT, unit: 'ft'}; } - return {value: meters.toFixed(decimals), unit: 'm'}; + return {value: meters, unit: 'm'}; } export function kmOrConversion( km: number, unitSystem: UnitSystem, -): {value: string; unit: string} { +): {value: number; unit: DistanceUnit} { if (unitSystem === 'imperial') { - return {value: (km * KM_TO_MI).toFixed(2), unit: 'mi'}; + return {value: km * KM_TO_MI, unit: 'mi'}; } - return {value: km.toFixed(2), unit: 'km'}; + return {value: km, unit: 'km'}; } export function metersPerSecondOrConversion( mps: number, unitSystem: UnitSystem, -): {value: string; unit: string} { +): {value: number; unit: SpeedUnit} { if (unitSystem === 'imperial') { - return {value: (mps * MS_TO_MPH).toFixed(2), unit: 'mph'}; + return {value: mps * MS_TO_FTS, unit: 'ft/s'}; } - return {value: mps.toFixed(2), unit: 'm/s'}; + return {value: mps, unit: 'm/s'}; } diff --git a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx index c984d10100..d4ed2414cc 100644 --- a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx +++ b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx @@ -231,7 +231,7 @@ export function MapReceivedBottomSheet({ unitSystem, ); return t(m.distanceAway, { - distance: `${value} ${unit}`, + distance: `${value.toFixed(2)} ${unit}`, }); })() : t(m.mbNeeded, {size: warningInfo.mbNeeded})} diff --git a/src/frontend/screens/ComapeoSettings/UnitSystemSettings.tsx b/src/frontend/screens/ComapeoSettings/UnitSystemSettings.tsx index a975d16c86..164e6559f9 100644 --- a/src/frontend/screens/ComapeoSettings/UnitSystemSettings.tsx +++ b/src/frontend/screens/ComapeoSettings/UnitSystemSettings.tsx @@ -6,7 +6,12 @@ import { useUnitSystem, useUnitSystemActions, } from '../../contexts/UnitSystemStoreContext'; -import {SelectOne} from '../../sharedComponents/SelectOne'; +import { + List, + ListItem, + ListItemText, + ListItemIcon, +} from '../../sharedComponents/List'; import {type NativeNavigationComponent} from '../../sharedTypes/navigation'; const m = defineMessages({ @@ -42,30 +47,38 @@ export const UnitSystemSettings: NativeNavigationComponent< const unitSystem = useUnitSystem(); const {setUnitSystem} = useUnitSystemActions(); - const options = [ - { - value: 'metric' as const, - label: t(m.metric), - hint: t(m.metricSubtitle), - }, - { - value: 'imperial' as const, - label: t(m.imperial), - hint: t(m.imperialSubtitle), - }, - ]; + const isMetric = unitSystem === 'metric'; + const isImperial = unitSystem === 'imperial'; return ( - { - setUnitSystem(selected); - }} - /> + + !isMetric && setUnitSystem('metric')}> + + + + !isImperial && setUnitSystem('imperial')}> + + + + ); }; diff --git a/src/frontend/screens/ComapeoSettings/index.tsx b/src/frontend/screens/ComapeoSettings/index.tsx index c31281d520..d52f69237d 100644 --- a/src/frontend/screens/ComapeoSettings/index.tsx +++ b/src/frontend/screens/ComapeoSettings/index.tsx @@ -17,7 +17,7 @@ import {BodyText} from '../../sharedComponents/Text/BodyText'; import {HeaderText} from '../../sharedComponents/Text/HeaderText'; import {BLACK, BLUE_GREY, COMAPEO_BLUE, NEW_DARK_GREY} from '../../lib/styles'; import HeartCheckIcon from '../../images/HeartCheck.svg'; -import AngleRulerIcon from '../../../images/AngleRuler.svg'; +import AngleRulerIcon from '../../images/AngleRuler.svg'; const m = defineMessages({ title: { diff --git a/src/frontend/screens/LocationInfoScreen.tsx b/src/frontend/screens/LocationInfoScreen.tsx index 0157e0de7d..b75a6cc9ae 100644 --- a/src/frontend/screens/LocationInfoScreen.tsx +++ b/src/frontend/screens/LocationInfoScreen.tsx @@ -98,11 +98,11 @@ export const LocationInfoScreen = () => { key === 'altitudeAccuracy' ) { const {value: v, unit} = metersOrConversion(value, unitSystem); - return `${v} ${unit}`; + return `${v.toFixed(0)} ${unit}`; } if (key === 'speed') { const {value: v, unit} = metersPerSecondOrConversion(value, unitSystem); - return `${v} ${unit}`; + return `${v.toFixed(2)} ${unit}`; } return value.toFixed(5); }; diff --git a/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx b/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx index 28184b3e0f..577762fdd4 100644 --- a/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx +++ b/src/frontend/screens/MapScreen/CurrentTrack/UserTooltipMarker.tsx @@ -16,6 +16,7 @@ export const UserTooltipMarker = () => { totalDistance, unitSystem, ); + const formattedDistance = distanceValue.toFixed(2); return ( // We dont want to put this check in the parent because it will cause the parent (the map) to render too often @@ -29,7 +30,7 @@ export const UserTooltipMarker = () => { - {distanceValue} {distanceUnit} + {formattedDistance} {distanceUnit} diff --git a/src/frontend/screens/Observation/Buttons.tsx b/src/frontend/screens/Observation/Buttons.tsx index 2addb3d9b9..51131df4ae 100644 --- a/src/frontend/screens/Observation/Buttons.tsx +++ b/src/frontend/screens/Observation/Buttons.tsx @@ -175,7 +175,7 @@ export const ButtonFields = ({ observation.metadata.position.coords.accuracy, unitSystem, ); - return `${t(m.precision)} ${value} ${unit}`; + return `${t(m.precision)} ${Math.round(value)} ${unit}`; })() : ''; diff --git a/src/frontend/screens/Observation/InsetMapView.tsx b/src/frontend/screens/Observation/InsetMapView.tsx index 8480c53e97..9ed977199e 100644 --- a/src/frontend/screens/Observation/InsetMapView.tsx +++ b/src/frontend/screens/Observation/InsetMapView.tsx @@ -65,9 +65,8 @@ export const InsetMapView = ({ const {value, unit} = metersOrConversion( accuracy, unitSystem, - 2, ); - return ` ± ${value} ${unit}`; + return ` ± ${value.toFixed(2)} ${unit}`; })()} diff --git a/src/frontend/screens/ObservationMetadata.tsx b/src/frontend/screens/ObservationMetadata.tsx index 4c086ed31c..52a4707067 100644 --- a/src/frontend/screens/ObservationMetadata.tsx +++ b/src/frontend/screens/ObservationMetadata.tsx @@ -23,6 +23,8 @@ import {useUnitSystem} from '../contexts/UnitSystemStoreContext'; import { metersOrConversion, metersPerSecondOrConversion, + getLengthUnit, + getSpeedUnit, } from '../lib/unitConversion'; import {useOpenShareDialog} from '../hooks/share'; import {useObservationWithPreset} from '../hooks/useObservationWithPreset'; @@ -125,12 +127,12 @@ export const ObservationMetadata: NativeNavigationComponent< const openShare = useOpenShareDialog(); const manualLocation = metadata?.manualLocation; - const lengthUnit = metersOrConversion(0, unitSystem).unit; - const speedUnit = metersPerSecondOrConversion(0, unitSystem).unit; + const lengthUnit = getLengthUnit(unitSystem); + const speedUnit = getSpeedUnit(unitSystem); const toMetersValue = (raw: number) => - metersOrConversion(raw, unitSystem).value; + Math.round(metersOrConversion(raw, unitSystem).value).toString(); const toSpeedValue = (raw: number) => - metersPerSecondOrConversion(raw, unitSystem).value; + metersPerSecondOrConversion(raw, unitSystem).value.toFixed(2); const listData: { [key: string]: { diff --git a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx index 5ea3e40c56..6800b137d8 100644 --- a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx +++ b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx @@ -235,7 +235,7 @@ export function AttachedPhotoPreviewModal({ metersFromObservation / 1000, unitSystem, ); - return `${value} ${unit}`; + return `${value.toFixed(2)} ${unit}`; } return metersFromObservation < 1000 ? formatNumber(metersFromObservation, { diff --git a/src/frontend/sharedComponents/LocationView.tsx b/src/frontend/sharedComponents/LocationView.tsx index f6484496ac..06d790be4d 100644 --- a/src/frontend/sharedComponents/LocationView.tsx +++ b/src/frontend/sharedComponents/LocationView.tsx @@ -46,7 +46,9 @@ export const LocationView = ({lat, lon, accuracy}: LocationViewProps) => { return ( {` ±${value} ${unit}`} + style={ + styles.accuracy + }>{` ±${Math.round(value)} ${unit}`} ); })()} diff --git a/src/frontend/sharedComponents/TrackStats.tsx b/src/frontend/sharedComponents/TrackStats.tsx index 188a5bd50d..50f6fade47 100644 --- a/src/frontend/sharedComponents/TrackStats.tsx +++ b/src/frontend/sharedComponents/TrackStats.tsx @@ -41,7 +41,7 @@ export const TrackStats = ({ - {displayDistance} {distanceUnit} + {displayDistance.toFixed(2)} {distanceUnit} diff --git a/tests/e2e/specs/settings/unit-system.test.ts b/tests/e2e/specs/settings/unit-system.test.ts index c7163001cf..283cd8f743 100644 --- a/tests/e2e/specs/settings/unit-system.test.ts +++ b/tests/e2e/specs/settings/unit-system.test.ts @@ -40,7 +40,7 @@ describe('Settings - Unit System Flow', () => { await $(byResourceId('MAP.gps-pill')).click(); await expect($(byResourceId('MAIN.gps-details-scrn'))).toBeDisplayed(); await expect($(byTextMatches('\\d+(\\.\\d+)? ft'))).toBeDisplayed(); - await expect($(byTextMatches('\\d+\\.\\d+ mph'))).toBeDisplayed(); + await expect($(byTextMatches('\\d+\\.\\d+ ft/s'))).toBeDisplayed(); }); it('should switch back to Metric via settings', async () => { From 7e1c3cb3b4964260578af67b9d61cd1e7b8520e7 Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Thu, 7 May 2026 14:16:43 -0400 Subject: [PATCH 8/9] Removes IIFE's. Fixes E2E test. Makes sure to use correct-matching units. --- .../BackgroundMaps/MapReceivedBottomSheet.tsx | 26 ++++---- .../screens/Observation/InsetMapView.tsx | 14 ++--- .../AttachedPhotoPreviewModal.tsx | 61 +++++++++++-------- tests/e2e/specs/settings/unit-system.test.ts | 9 +-- 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx index d4ed2414cc..5865a2ee26 100644 --- a/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx +++ b/src/frontend/screens/BackgroundMaps/MapReceivedBottomSheet.tsx @@ -188,6 +188,20 @@ export function MapReceivedBottomSheet({ } }; + let warningSubtitle = null; + + if (warningInfo.warning === 'location') { + const {value: distanceValue, unit: distanceUnit} = kmOrConversion( + warningInfo.distanceKm ?? 0, + unitSystem, + ); + warningSubtitle = t(m.distanceAway, { + distance: `${distanceValue.toFixed(2)} ${distanceUnit}`, + }); + } else if (warningInfo.warning === 'space') { + warningSubtitle = t(m.mbNeeded, {size: warningInfo.mbNeeded}); + } + return ( @@ -224,17 +238,7 @@ export function MapReceivedBottomSheet({ : t(m.notEnoughSpace)} - {warningInfo.warning === 'location' - ? (() => { - const {value, unit} = kmOrConversion( - warningInfo.distanceKm ?? 0, - unitSystem, - ); - return t(m.distanceAway, { - distance: `${value.toFixed(2)} ${unit}`, - }); - })() - : t(m.mbNeeded, {size: warningInfo.mbNeeded})} + {warningSubtitle} diff --git a/src/frontend/screens/Observation/InsetMapView.tsx b/src/frontend/screens/Observation/InsetMapView.tsx index 9ed977199e..fbb0484bf9 100644 --- a/src/frontend/screens/Observation/InsetMapView.tsx +++ b/src/frontend/screens/Observation/InsetMapView.tsx @@ -31,6 +31,11 @@ export const InsetMapView = ({ const unitSystem = useUnitSystem(); const {data: styleUrl} = useMapStyleJsonUrl(); const {navigate} = useNavigationFromRoot(); + let accuracyItem = ''; + if (accuracy) { + const {value, unit} = metersOrConversion(accuracy, unitSystem); + accuracyItem = ` ± ${value.toFixed(2)} ${unit}`; + } return ( - {accuracy && - (() => { - const {value, unit} = metersOrConversion( - accuracy, - unitSystem, - ); - return ` ± ${value.toFixed(2)} ${unit}`; - })()} + {accuracyItem} diff --git a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx index 6800b137d8..ffe56f2cb1 100644 --- a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx +++ b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx @@ -24,8 +24,11 @@ import { import {CoreBlobImage} from '../../sharedComponents/Images/CoreBlobImage.tsx'; import {ImageErrorPlaceholder} from '../../sharedComponents/Images/ImageErrorPlaceholder.tsx'; import {useActiveProject} from '../../contexts/ActiveProjectContext.tsx'; -import {useUnitSystem} from '../../contexts/UnitSystemStoreContext'; -import {kmOrConversion} from '../../lib/unitConversion'; +import { + useUnitSystem, + type UnitSystem, +} from '../../contexts/UnitSystemStoreContext'; +import {kmOrConversion, metersOrConversion} from '../../lib/unitConversion'; import MaterialIcons from '@react-native-vector-icons/material-icons'; import {BodyText} from '../../sharedComponents/Text/BodyText.tsx'; import {sharedStyles} from './sharedStyles.ts'; @@ -35,6 +38,33 @@ import {useAppLanguageTag} from '../../hooks/useAppLanguageTag.ts'; import {Accordian} from '../../sharedComponents/Accordian.tsx'; import Octicons from '@react-native-vector-icons/octicons'; +function formatDistance( + meters: number, + unitSystem: UnitSystem, + formatNumber: (value: number, opts: object) => string, +): string { + if (unitSystem === 'imperial') { + if (meters < 1000) { + const {value, unit} = metersOrConversion(meters, unitSystem); + return `${formatNumber(value, {maximumFractionDigits: 2})} ${unit}`; + } + const {value, unit} = kmOrConversion(meters / 1000, unitSystem); + return `${formatNumber(value, {maximumFractionDigits: 2})} ${unit}`; + } + if (meters < 1000) { + return formatNumber(meters, { + style: 'unit', + unit: 'meter', + maximumFractionDigits: 2, + }); + } + return formatNumber(meters / 1000, { + style: 'unit', + unit: 'kilometer', + maximumFractionDigits: 2, + }); +} + const m = defineMessages({ validatedByCoMapeo: { id: 'screens.PhotoPreviewModal.validatedByCoMapeo', @@ -142,6 +172,10 @@ export function AttachedPhotoPreviewModal({ ) : null; + const distance = metersFromObservation + ? formatDistance(metersFromObservation, unitSystem, formatNumber) + : null; + return ( @@ -228,28 +262,7 @@ export function AttachedPhotoPreviewModal({ /> }> - {formatMessage(m.distanceFromObs, { - distance: (() => { - if (unitSystem === 'imperial') { - const {value, unit} = kmOrConversion( - metersFromObservation / 1000, - unitSystem, - ); - return `${value.toFixed(2)} ${unit}`; - } - return metersFromObservation < 1000 - ? formatNumber(metersFromObservation, { - style: 'unit', - unit: 'meter', - maximumFractionDigits: 2, - }) - : formatNumber(metersFromObservation / 1000, { - style: 'unit', - unit: 'kilometer', - maximumFractionDigits: 2, - }); - })(), - })} + {formatMessage(m.distanceFromObs, {distance})} )} diff --git a/tests/e2e/specs/settings/unit-system.test.ts b/tests/e2e/specs/settings/unit-system.test.ts index 283cd8f743..5bbcbd3de3 100644 --- a/tests/e2e/specs/settings/unit-system.test.ts +++ b/tests/e2e/specs/settings/unit-system.test.ts @@ -12,7 +12,7 @@ describe('Settings - Unit System Flow', () => { }); it('should open Unit System screen', async () => { - const unitSystemOption = await $(byTextMatches('Unit System')); + const unitSystemOption = await $(byResourceId('unitSystemButton')); await unitSystemOption.click(); await expect($(byTextMatches('Metric'))).toBeDisplayed(); @@ -43,7 +43,7 @@ describe('Settings - Unit System Flow', () => { await expect($(byTextMatches('\\d+\\.\\d+ ft/s'))).toBeDisplayed(); }); - it('should switch back to Metric via settings', async () => { + it('should display the unit system chosen on the settings menu', async () => { const backBtn = await $(byResourceId('MAIN.header-back-btn')); await backBtn.click(); @@ -53,9 +53,10 @@ describe('Settings - Unit System Flow', () => { const appSettingsOption = await $('~Go to app settings screen.'); await appSettingsOption.click(); - const unitSystemOption = await $(byTextMatches('Unit System')); + const unitSystemOption = await $(byTextMatches('Imperial')); await unitSystemOption.click(); - + }); + it('should display the unit system chosen on the settings menu', async () => { const imperialButton = await $(byResourceId('imperialButton-selected')); await expect(imperialButton).toBeDisplayed(); From dbb984c42ad130d85c5c0fb78f23dd90c59d7034 Mon Sep 17 00:00:00 2001 From: Cindy Green Date: Wed, 20 May 2026 14:14:12 -0400 Subject: [PATCH 9/9] Just exports context and not provider to use in App Providers. Takes out intl number formatting since it is only used in one place. --- src/frontend/contexts/AppProviders.tsx | 6 ++--- .../contexts/UnitSystemStoreContext.ts | 14 ++--------- .../AttachedPhotoPreviewModal.tsx | 24 +++++-------------- 3 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/frontend/contexts/AppProviders.tsx b/src/frontend/contexts/AppProviders.tsx index e6f8ea39af..89afb29fe6 100644 --- a/src/frontend/contexts/AppProviders.tsx +++ b/src/frontend/contexts/AppProviders.tsx @@ -25,7 +25,7 @@ import { } from './CoordinateFormatStoreContext'; import { type UnitSystemStore, - UnitSystemStoreProvider, + UnitSystemStoreContext, } from './UnitSystemStoreContext'; import { type ManualEntryCoordinateFormatStore, @@ -93,7 +93,7 @@ export const AppProviders = ({ unitSystemStore, }: AppProvidersProps) => { return ( - + @@ -142,7 +142,7 @@ export const AppProviders = ({ - + ); }; diff --git a/src/frontend/contexts/UnitSystemStoreContext.ts b/src/frontend/contexts/UnitSystemStoreContext.ts index ff5aaa0c3f..3491aa9985 100644 --- a/src/frontend/contexts/UnitSystemStoreContext.ts +++ b/src/frontend/contexts/UnitSystemStoreContext.ts @@ -1,4 +1,4 @@ -import React, {createContext, useContext} from 'react'; +import {createContext, useContext} from 'react'; import {createStore, useStore, type StoreApi} from 'zustand'; import { createJSONStorage, @@ -55,21 +55,11 @@ export const UnitSystemStoreContext = createContext( null, ); -export function UnitSystemStoreProvider({ - value, - children, -}: { - value: UnitSystemStore; - children: React.ReactNode; -}) { - return React.createElement(UnitSystemStoreContext, {value}, children); -} - function useUnitSystemStoreContext() { const value = useContext(UnitSystemStoreContext); if (!value) { - throw new Error('Must set up the UnitSystemStoreProvider first'); + throw new Error('Must set up the UnitSystemStoreContext first'); } return value; diff --git a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx index ffe56f2cb1..cd13e5e49a 100644 --- a/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx +++ b/src/frontend/screens/PhotoPreviewModal/AttachedPhotoPreviewModal.tsx @@ -38,31 +38,19 @@ import {useAppLanguageTag} from '../../hooks/useAppLanguageTag.ts'; import {Accordian} from '../../sharedComponents/Accordian.tsx'; import Octicons from '@react-native-vector-icons/octicons'; -function formatDistance( - meters: number, - unitSystem: UnitSystem, - formatNumber: (value: number, opts: object) => string, -): string { +function formatDistance(meters: number, unitSystem: UnitSystem): string { if (unitSystem === 'imperial') { if (meters < 1000) { const {value, unit} = metersOrConversion(meters, unitSystem); - return `${formatNumber(value, {maximumFractionDigits: 2})} ${unit}`; + return `${value.toFixed(2)} ${unit}`; } const {value, unit} = kmOrConversion(meters / 1000, unitSystem); - return `${formatNumber(value, {maximumFractionDigits: 2})} ${unit}`; + return `${value.toFixed(2)} ${unit}`; } if (meters < 1000) { - return formatNumber(meters, { - style: 'unit', - unit: 'meter', - maximumFractionDigits: 2, - }); + return `${meters.toFixed(2)} m`; } - return formatNumber(meters / 1000, { - style: 'unit', - unit: 'kilometer', - maximumFractionDigits: 2, - }); + return `${(meters / 1000).toFixed(2)} km`; } const m = defineMessages({ @@ -173,7 +161,7 @@ export function AttachedPhotoPreviewModal({ : null; const distance = metersFromObservation - ? formatDistance(metersFromObservation, unitSystem, formatNumber) + ? formatDistance(metersFromObservation, unitSystem) : null; return (