Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: new draft observation hook #871

Open
wants to merge 13 commits into
base: uk-retreat/improvements
Choose a base branch
from
Open
28 changes: 25 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
"eas-build-post-install": "npm run build:backend && npm run build:translations && npm run build:intl-polyfills",
"tests": "maestro start-device --platform android && npm run android && maestro test e2e",
"upgrade-dependencies:all": "npm-check --update --save-exact",
"upgrade-dependencies:production":"npm-check --update --save-exact --production",
"upgrade-dependencies:dev-deps":"npm-check --update --save-exact --dev-only"
"upgrade-dependencies:production": "npm-check --update --save-exact --production",
"upgrade-dependencies:dev-deps": "npm-check --update --save-exact --dev-only"
},
"dependencies": {
"@bam.tech/react-native-image-resizer": "3.0.11",
Expand Down Expand Up @@ -115,7 +115,8 @@
"utm": "1.1.1",
"valibot": "0.42.1",
"validate-color": "2.2.4",
"zustand": "5.0.1"
"zustand": "5.0.1",
"expo-image-manipulator": "11.8.0"
},
"devDependencies": {
"@babel/core": "7.25.7",
Expand Down
13 changes: 8 additions & 5 deletions src/frontend/contexts/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {BottomSheetModalProvider} from '@gorhom/bottom-sheet';
import {MetricsProvider} from './MetricsContext';
import {AppDiagnosticMetrics} from '../metrics/AppDiagnosticMetrics';
import {DeviceDiagnosticMetrics} from '../metrics/DeviceDiagnosticMetrics';
import {DraftObservationProvider} from './DraftObservationContext';

type AppProvidersProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -60,11 +61,13 @@ export const AppProviders = ({
appMetrics={appMetrics}
deviceMetrics={deviceMetrics}>
<ActiveProjectProvider>
<BottomSheetModalProvider>
<PhotoPromiseProvider>
<SecurityProvider>{children}</SecurityProvider>
</PhotoPromiseProvider>
</BottomSheetModalProvider>
<DraftObservationProvider>
<BottomSheetModalProvider>
<PhotoPromiseProvider>
<SecurityProvider>{children}</SecurityProvider>
</PhotoPromiseProvider>
</BottomSheetModalProvider>
</DraftObservationProvider>
</ActiveProjectProvider>
</MetricsProvider>
</ApiProvider>
Expand Down
164 changes: 164 additions & 0 deletions src/frontend/contexts/DraftObservationContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import * as Location from 'expo-location';
import {AppState, type AppStateStatus} from 'react-native';
import CheapRuler from 'cheap-ruler';
import React, {
PropsWithChildren,
createContext,
useContext,
useState,
} from 'react';
import {
convertPosition,
createDraftObservationStore,
type DraftState,
} from '../hooks/persistedState/usePersistedDraftObservationNew';

const DraftObservationContext = createContext<ReturnType<
typeof createDraftObservationStore
> | null>(null);

export function DraftObservationProvider({children}: PropsWithChildren<{}>) {
const [value] = useState(() => {
const store = createDraftObservationStore();
createDraftObservationLocationUpdator(store);
return store;
});

return (
<DraftObservationContext.Provider value={value}>
{children}
</DraftObservationContext.Provider>
);
}

export function useDraftObservationContext() {
const result = useContext(DraftObservationContext);

if (!result) {
throw new Error('Must set up the DraftObservationContext Provider first');
}

return result;
}

const LOCATION_OPTIONS: Location.LocationOptions = {
accuracy: Location.Accuracy.Highest,
timeInterval: 1000,
};
/** We don't update the position of an observation with the location from the
* device location provider if the location is older than this threshold */
const STALE_LOCATION_THRESHOLD_MS = 1000;
/** Over this threshold we consider the user to have moved away from the
* location of the observation, and we stop refining GPS position. We use the
* accuracy of the first location as the default, and use this as a fallback if
* we do not have an accuracy value. */
const MOVED_AWAY_THRESHOLD_METERS = 100;
/** The factor of accuracy that a new location must be to consider the user to
* have moved away. E.g. if the accuracy of the initial position is 10m and the
* factor is 1.5, then we consider the user to have moved away if a location
* update is 15m away. */
const ACCURACY_MOVED_AWAY_FACTOR = 1.5;

export async function createDraftObservationLocationUpdator({
store,
actions: {updatePosition},
}: ReturnType<typeof createDraftObservationStore>) {
let {granted: locationPermissionGranted} =
await Location.getForegroundPermissionsAsync();
let locationSubscriptionPromise: Promise<Location.LocationSubscription> | null =
null;
let appState: AppStateStatus = AppState.currentState;
let isNewlyCreatedDraftInStore = isNewlyCreatedDraft(store.getState());

AppState.addEventListener('change', async nextAppState => {
appState = nextAppState;
if (appState === 'active' && !locationPermissionGranted) {
locationPermissionGranted = (
await Location.getForegroundPermissionsAsync()
).granted;
}
watchPositionIfNeeded();
});

store.subscribe(storeState => {
isNewlyCreatedDraftInStore = isNewlyCreatedDraft(storeState);
watchPositionIfNeeded();
});

async function watchPositionIfNeeded() {
const shouldBeWatchingPosition =
appState === 'active' &&
isNewlyCreatedDraftInStore &&
locationPermissionGranted;

if (shouldBeWatchingPosition && !locationSubscriptionPromise) {
locationSubscriptionPromise = Location.watchPositionAsync(
LOCATION_OPTIONS,
onPositionUpdate,
);
} else if (!shouldBeWatchingPosition && locationSubscriptionPromise) {
// Avoid a race condition by nulling the state before awaiting the promise
const locationSubscriptionPromiseCopy = locationSubscriptionPromise;
locationSubscriptionPromise = null;
const subscription = await locationSubscriptionPromiseCopy;
subscription.remove();
}
}

function onPositionUpdate(location: Location.LocationObject) {
const {value: currentDraft, initialPosition} = store.getState();
if (!currentDraft) return;

const isManualLocation = !!currentDraft.metadata?.manualLocation;
if (isManualLocation) return;

const isStale =
Date.now() - location.timestamp > STALE_LOCATION_THRESHOLD_MS;
if (isStale) return;

store.setState(prev => {
if (prev.initialPosition || !prev.value) return prev;
return {...prev, initialPosition: convertPosition(location)};
});

const initialAccuracy = initialPosition?.coords.accuracy;
const movedAwayThreshold = initialAccuracy
? initialAccuracy * ACCURACY_MOVED_AWAY_FACTOR
: MOVED_AWAY_THRESHOLD_METERS;
const hasMovedAway =
initialPosition &&
distanceBetweenCoords(initialPosition.coords, location.coords) >
movedAwayThreshold;
if (hasMovedAway) return;

const currentDraftCoords = currentDraft.metadata?.position?.coords;

const isMoreAccurate =
!currentDraftCoords ||
!location.coords.accuracy ||
!currentDraftCoords.accuracy ||
location.coords.accuracy < currentDraftCoords.accuracy;

if (!isMoreAccurate) return;

updatePosition({
manualLocation: false,
position: location,
// TODO: Also add positionProvider. Probably needs access to the locationProvider store.
});
}
}

function isNewlyCreatedDraft(storeState: DraftState) {
const isObservationInStore = !!storeState.value;
const isNewlyCreatedObservation = !storeState.id;
return isObservationInStore && isNewlyCreatedObservation;
}

function distanceBetweenCoords(
a: {latitude: number; longitude: number},
b: {latitude: number; longitude: number},
) {
const ruler = new CheapRuler(a.latitude);
return ruler.distance([a.longitude, a.latitude], [b.longitude, b.latitude]);
}
37 changes: 37 additions & 0 deletions src/frontend/hooks/draftObservation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {useStore} from 'zustand';

import {useDraftObservationContext} from '../contexts/DraftObservationContext';
import {
DraftState,
DraftStatePopulated,
} from '../hooks/persistedState/usePersistedDraftObservationNew';
import {useCallback} from 'react';

function defaultSelector(state: DraftStatePopulated) {
return state;
}

export function useDraftObservation<S = DraftStatePopulated>(
selector: (state: DraftStatePopulated) => S = defaultSelector as any,
) {
const {store} = useDraftObservationContext();

const assertPopulatedStateSelector = useCallback(
(state: DraftState) => {
if (!state.value) {
throw new Error('No observation to read');
}
return selector(state);
},
[selector],
);

const value = useStore(store, assertPopulatedStateSelector);

return value;
}

export function useDraftObservationActions() {
const {actions} = useDraftObservationContext();
return actions;
}
Loading
Loading