diff --git a/package-lock.json b/package-lock.json
index a77af837c..898f215bb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,6 +46,7 @@
"expo-document-picker": "11.10.1",
"expo-file-system": "16.0.9",
"expo-font": "11.10.3",
+ "expo-image-manipulator": "11.8.0",
"expo-localization": "14.8.4",
"expo-location": "16.5.5",
"expo-secure-store": "12.8.1",
@@ -14675,6 +14676,27 @@
"expo": "*"
}
},
+ "node_modules/expo-image-loader": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.6.0.tgz",
+ "integrity": "sha512-RHQTDak7/KyhWUxikn2yNzXL7i2cs16cMp6gEAgkHOjVhoCJQoOJ0Ljrt4cKQ3IowxgCuOrAgSUzGkqs7omj8Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-image-manipulator": {
+ "version": "11.8.0",
+ "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-11.8.0.tgz",
+ "integrity": "sha512-ZWVrHnYmwJq6h7auk+ropsxcNi+LyZcPFKQc8oy+JA0SaJosfShvkCm7RADWAunHmfPCmjHrhwPGEu/rs7WG/A==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-image-loader": "~4.6.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-json-utils": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.12.3.tgz",
@@ -28207,9 +28229,9 @@
}
},
"node_modules/use-sync-external-store": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
- "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
+ "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
"license": "MIT",
"optional": true,
"peer": true,
diff --git a/package.json b/package.json
index 311037e38..e0faa6882 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
diff --git a/src/frontend/contexts/AppProviders.tsx b/src/frontend/contexts/AppProviders.tsx
index 903ddfe1e..090522e54 100644
--- a/src/frontend/contexts/AppProviders.tsx
+++ b/src/frontend/contexts/AppProviders.tsx
@@ -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;
@@ -60,11 +61,13 @@ export const AppProviders = ({
appMetrics={appMetrics}
deviceMetrics={deviceMetrics}>
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
diff --git a/src/frontend/contexts/DraftObservationContext.tsx b/src/frontend/contexts/DraftObservationContext.tsx
new file mode 100644
index 000000000..5e4c6ad4d
--- /dev/null
+++ b/src/frontend/contexts/DraftObservationContext.tsx
@@ -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 | null>(null);
+
+export function DraftObservationProvider({children}: PropsWithChildren<{}>) {
+ const [value] = useState(() => {
+ const store = createDraftObservationStore();
+ createDraftObservationLocationUpdator(store);
+ return store;
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+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) {
+ let {granted: locationPermissionGranted} =
+ await Location.getForegroundPermissionsAsync();
+ let locationSubscriptionPromise: Promise | 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]);
+}
diff --git a/src/frontend/hooks/draftObservation.ts b/src/frontend/hooks/draftObservation.ts
new file mode 100644
index 000000000..6d810e4d7
--- /dev/null
+++ b/src/frontend/hooks/draftObservation.ts
@@ -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(
+ 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;
+}
diff --git a/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts
new file mode 100644
index 000000000..e0c162b1f
--- /dev/null
+++ b/src/frontend/hooks/persistedState/usePersistedDraftObservationNew.ts
@@ -0,0 +1,514 @@
+import {
+ type MapeoDoc,
+ type Observation,
+ type ObservationValue,
+ type Preset,
+} from '@comapeo/schema';
+import {createPersistedStore} from './createPersistedState.ts';
+import type {LocationObject, LocationProviderStatus} from 'expo-location';
+import type {AccelerometerMeasurement} from 'expo-sensors';
+import type {CameraCapturedPicture} from 'expo-camera';
+import {manipulateAsync} from 'expo-image-manipulator';
+import {excludeKeys} from 'filter-obj';
+import type {Position} from '../../sharedTypes/index.ts';
+
+type ObservationTagValue = Observation['tags'][number];
+
+type UnsavedAttachmentBlob =
+ | {
+ uri: string;
+ processingState: 'complete';
+ }
+ | {
+ uri: null;
+ processingState: 'pending';
+ }
+ | {
+ uri: null;
+ error: Error;
+ processingState: 'error';
+ };
+
+type UnsavedPhotoAttachment = {
+ id: number;
+ type: 'photo';
+ // Represents unprocessed blob (i.e. not resized or rotated)
+ raw: UnsavedAttachmentBlob;
+ original: UnsavedAttachmentBlob;
+ thumbnail: UnsavedAttachmentBlob;
+ preview: UnsavedAttachmentBlob;
+ accelerometer?: AccelerometerMeasurement;
+ location?: LocationObject;
+ timestamp: number;
+ abortController: AbortController;
+};
+
+export type PhotoMetadata = Pick<
+ UnsavedPhotoAttachment,
+ 'location' | 'accelerometer' | 'timestamp'
+>;
+
+/** Position update from manual coordinate entry */
+type ManualPosition = {
+ coords: {
+ latitude: number;
+ longitude: number;
+ };
+};
+
+type UnsavedAudioAttachment = {
+ id: number;
+ original: UnsavedAttachmentBlob;
+ type: 'audio';
+ abortController: AbortController;
+};
+
+type UnsavedAttachment = UnsavedPhotoAttachment | UnsavedAudioAttachment;
+
+type DraftStateEmpty = {
+ value: null;
+ id: null;
+ unsavedAttachments: null;
+ initialPosition: null;
+};
+
+type ObservationWithPreset = Exclude & {
+ presetRef?: Preset;
+};
+
+type ObservationValueWithPreset = Exclude & {
+ presetRef?: Preset;
+};
+
+export type DraftStatePopulated = {
+ value: ObservationValueWithPreset;
+ id: {docId: string; versionId: string} | null;
+ unsavedAttachments: Map;
+ /** Initial (first) position of an observation. Not currently persisted, but
+ * used for checking if the user moves away from the original location */
+ initialPosition: Position | null;
+};
+
+export type DraftState = DraftStateEmpty | DraftStatePopulated;
+
+const ORIGINAL_COMPRESSION = 0.75;
+const THUMBNAIL_SIZE = 400;
+const THUMBNAIL_COMPRESSION = 0.3;
+const PREVIEW_SIZE = 1200;
+const PREVIEW_COMPRESSION = 0.3;
+
+function createEmptyStoreState(): DraftStateEmpty {
+ return {
+ value: null,
+ id: null,
+ unsavedAttachments: null,
+ initialPosition: null,
+ };
+}
+
+function createEmptyObservationValue(): ObservationValueWithPreset {
+ return {
+ schemaName: 'observation',
+ lat: 0,
+ lon: 0,
+ metadata: {manualLocation: false},
+ tags: {
+ notes: '',
+ },
+ attachments: [],
+ };
+}
+
+function createNewPhotoAttachment(
+ id: number,
+ metadata: PhotoMetadata,
+): UnsavedAttachment {
+ return {
+ id,
+ type: 'photo',
+ raw: {uri: null, processingState: 'pending'},
+ original: {uri: null, processingState: 'pending'},
+ thumbnail: {uri: null, processingState: 'pending'},
+ preview: {uri: null, processingState: 'pending'},
+ abortController: new AbortController(),
+ ...metadata,
+ };
+}
+
+export function createDraftObservationStore() {
+ let nextAttachmentId = 0;
+
+ const store = createPersistedStore(
+ () => createEmptyStoreState(),
+ '@MapeoDraft',
+ );
+
+ /** Helper to set state, but throw if store is empty (no draft observation) */
+ function setAssertDraft(
+ partial: (state: DraftStatePopulated) => Partial,
+ ): void {
+ return store.setState(prev => {
+ if (prev.value === null) {
+ throw new Error('No observation to update');
+ }
+ return partial(prev);
+ });
+ }
+
+ function _addAttachment(attachment: UnsavedAttachment): void {
+ setAssertDraft(prev => {
+ return {
+ unsavedAttachments: new Map(prev.unsavedAttachments).set(
+ attachment.id,
+ attachment,
+ ),
+ };
+ });
+ }
+
+ function _updateAttachment(
+ type: T,
+ id: number,
+ partial: Partial>,
+ ): void {
+ setAssertDraft(prev => {
+ const attachment = prev.unsavedAttachments.get(id);
+ if (!attachment) return prev;
+ if (attachment.type !== type) {
+ throw new Error(`Attachment with id ${id} is not of type ${type}`);
+ }
+
+ return {
+ unsavedAttachments: new Map(prev.unsavedAttachments).set(id, {
+ ...attachment,
+ ...partial,
+ }),
+ };
+ });
+ }
+
+ async function _processPhotoAttachment({
+ id,
+ outputKey,
+ processPromise,
+ }: {
+ id: number;
+ outputKey: 'thumbnail' | 'preview' | 'original' | 'raw';
+ processPromise: Promise;
+ }): Promise {
+ try {
+ const processResult = await processPromise;
+ _updateAttachment('photo', id, {
+ [outputKey]: {uri: processResult.uri, processingState: 'complete'},
+ });
+ return processResult;
+ } catch (reason) {
+ const error = reasonToError(reason);
+ _updateAttachment('photo', id, {
+ [outputKey]: {uri: null, processingState: 'error', error},
+ });
+ throw reason;
+ }
+ }
+
+ async function addPhoto(
+ capturePromise: Promise,
+ metadata: PhotoMetadata,
+ ) {
+ const newAttachment = createNewPhotoAttachment(
+ nextAttachmentId++,
+ metadata,
+ );
+ const {
+ abortController: {signal},
+ id,
+ } = newAttachment;
+ _addAttachment(newAttachment);
+
+ try {
+ const {uri: rawUri} = await _processPhotoAttachment({
+ id,
+ outputKey: 'raw',
+ processPromise: capturePromise,
+ });
+ signal.throwIfAborted();
+
+ // TODO: Previously, rotation of the original photo could fail on older
+ // devices with low memory, so we would skip rotation and save the raw
+ // image as the original, and then rotate the preview and thumbnail, which
+ // would work. However hopefully the expo image manipulator does not fail
+ // in the same way, so we will not need that workaround. If we do get
+ // failures reported, then we should consider adding it back in.
+ const {
+ uri: originalUri,
+ width,
+ height,
+ } = await _processPhotoAttachment({
+ id,
+ outputKey: 'original',
+ processPromise: manipulateAsync(
+ rawUri,
+ [{rotate: getPhotoRotation(metadata.accelerometer)}],
+ {compress: ORIGINAL_COMPRESSION},
+ ),
+ });
+ signal.throwIfAborted();
+
+ const thumbnailDimensions =
+ width > height ? {width: THUMBNAIL_SIZE} : {height: THUMBNAIL_SIZE};
+ await _processPhotoAttachment({
+ id,
+ outputKey: 'thumbnail',
+ processPromise: manipulateAsync(
+ originalUri,
+ [{resize: thumbnailDimensions}],
+ {compress: THUMBNAIL_COMPRESSION},
+ ),
+ });
+ signal.throwIfAborted();
+
+ const previewDimensions =
+ width > height ? {width: PREVIEW_SIZE} : {height: PREVIEW_SIZE};
+ await _processPhotoAttachment({
+ id,
+ outputKey: 'preview',
+ processPromise: manipulateAsync(
+ originalUri,
+ [{resize: previewDimensions}],
+ {compress: PREVIEW_COMPRESSION},
+ ),
+ });
+ signal.throwIfAborted();
+ } catch (reason) {
+ if (reason instanceof Error && reason.name === 'AbortError') {
+ // TODO: Remove attachment from state
+ }
+ // TODO: Report other errors to Sentry
+ }
+ }
+
+ function addAudio(uri: string) {
+ const newAttachment: UnsavedAudioAttachment = {
+ id: nextAttachmentId++,
+ type: 'audio',
+ original: {uri, processingState: 'complete'},
+ abortController: new AbortController(),
+ };
+ _addAttachment(newAttachment);
+ }
+
+ function deleteUnsavedAttachment(id: number) {
+ setAssertDraft(prev => {
+ if (!prev.unsavedAttachments.has(id)) return prev;
+ const attachments = new Map(prev.unsavedAttachments);
+ attachments.delete(id);
+ return {unsavedAttachments: attachments};
+ });
+ }
+
+ function clearDraft() {
+ store.setState(createEmptyStoreState(), true);
+ }
+
+ function createDraft(observation?: ObservationWithPreset) {
+ if (observation) {
+ store.setState(
+ {
+ value: valueOf(observation),
+ id: {docId: observation.docId, versionId: observation.versionId},
+ unsavedAttachments: new Map(),
+ initialPosition: null,
+ },
+ true,
+ );
+ } else {
+ store.setState(
+ {
+ value: createEmptyObservationValue(),
+ id: null,
+ unsavedAttachments: new Map(),
+ initialPosition: null,
+ },
+ true,
+ );
+ }
+ }
+
+ function updatePosition(
+ opts:
+ | {
+ manualLocation: false;
+ position: LocationObject;
+ // TODO: Optional for now until we integrate this into the draft observation location updater
+ positionProvider?: LocationProviderStatus;
+ }
+ | {
+ manualLocation: true;
+ position: ManualPosition;
+ },
+ ) {
+ const {position, ...rest} = opts;
+ const metadata: ObservationValue['metadata'] = {
+ ...rest,
+ position: convertPosition(position),
+ };
+ setAssertDraft(prev => {
+ // Forward compat - make sure we keep any other metadata that we don't know about
+ const prevMetadataToKeep = excludeKeys(prev.value.metadata || {}, [
+ 'manualLocation',
+ 'position',
+ 'positionProvider',
+ ]);
+ return {
+ value: {
+ ...prev.value,
+ lat: position.coords.latitude,
+ lon: position.coords.longitude,
+ metadata: {
+ ...prevMetadataToKeep,
+ ...metadata,
+ },
+ },
+ };
+ });
+ }
+
+ function updateTag(tagKey: string, value: ObservationTagValue): void {
+ setAssertDraft(prev => {
+ return {
+ value: {
+ ...prev.value,
+ tags: {...prev.value.tags, [tagKey]: value},
+ },
+ };
+ });
+ }
+
+ function updatePreset(preset: Preset) {
+ setAssertDraft(prev => {
+ const prevPreset = prev.value.presetRef;
+ if (!prevPreset) {
+ return {
+ value: {
+ ...prev.value,
+ presetRef: preset,
+ tags: {
+ ...prev.value.tags,
+ ...preset.tags,
+ ...preset.addTags,
+ },
+ },
+ };
+ }
+ // Apply tags from new preset and remove tags from previous preset
+ const newTags: Observation['tags'] = {...preset.tags, ...preset.addTags};
+ for (const [key, value] of Object.entries(prev.value.tags)) {
+ const tagWasFromPrevPreset =
+ prevPreset.tags[key] === value || prevPreset.addTags[key] === value;
+ const shouldRemoveTag = preset.removeTags[key] === value;
+ // Only keep tags that were not from the previous preset and are not removed by the new preset
+ if (!tagWasFromPrevPreset && !shouldRemoveTag) {
+ newTags[key] = value;
+ }
+ }
+
+ return {
+ value: {
+ ...prev.value,
+ presetRef: preset,
+ tags: newTags,
+ },
+ };
+ });
+ }
+
+ const actions = {
+ addPhoto,
+ addAudio,
+ deleteUnsavedAttachment,
+ clearDraft,
+ createDraft,
+ updatePosition,
+ updateTag,
+ updatePreset,
+ };
+
+ return {store, actions};
+}
+
+function reasonToError(reason: unknown): Error {
+ if (reason instanceof Error) {
+ return reason;
+ }
+ if (typeof reason === 'string') {
+ return new Error(reason);
+ }
+ return new Error('Unknown error');
+}
+
+const ACC_AT_45_DEG = Math.sin(Math.PI / 4);
+
+function getPhotoRotation(acc?: AccelerometerMeasurement) {
+ if (!acc) return 0;
+ const {x, y, z} = acc;
+ let rotation = 0;
+ if (z < -ACC_AT_45_DEG || z > ACC_AT_45_DEG) {
+ // camera is pointing up or down
+ if (Math.abs(y) > Math.abs(x)) {
+ // camera is vertical
+ if (y <= 0) rotation = 180;
+ else rotation = 0;
+ } else {
+ // camera is horizontal
+ if (x >= 0) rotation = -90;
+ else rotation = 90;
+ }
+ } else if (x > -ACC_AT_45_DEG && x < ACC_AT_45_DEG) {
+ // camera is vertical
+ if (y <= 0) rotation = 180;
+ else rotation = 0;
+ } else {
+ // camera is horizontal
+ if (x >= 0) rotation = -90;
+ else rotation = 90;
+ }
+ return rotation;
+}
+
+export function convertPosition(
+ location: LocationObject | ManualPosition,
+): Position {
+ const {coords} = location;
+ return {
+ coords: {
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ // @ts-expect-error - too much work to fix this, not a runtime issue
+ accuracy: coords.accuracy ?? undefined,
+ // @ts-expect-error - too much work to fix this, not a runtime issue
+ altitude: coords.altitude ?? undefined,
+ // @ts-expect-error - too much work to fix this, not a runtime issue
+ heading: coords.heading ?? undefined,
+ // @ts-expect-error - too much work to fix this, not a runtime issue
+ speed: coords.speed ?? undefined,
+ },
+ timestamp:
+ 'timestamp' in location
+ ? new Date(location.timestamp).toISOString()
+ : new Date().toISOString(),
+ };
+}
+
+// TODO: Move this to @mapeo/schema - the current version is not flexible enough
+function valueOf(doc: T & {forks?: string[]}) {
+ return excludeKeys(doc, [
+ 'docId',
+ 'versionId',
+ 'originalVersionId',
+ 'links',
+ 'forks',
+ 'createdAt',
+ 'updatedAt',
+ 'deleted',
+ ]);
+}
diff --git a/src/frontend/hooks/useDraftObservation.ts b/src/frontend/hooks/useDraftObservation.ts
index fb15ef035..74cf49099 100644
--- a/src/frontend/hooks/useDraftObservation.ts
+++ b/src/frontend/hooks/useDraftObservation.ts
@@ -15,6 +15,9 @@ import * as Sentry from '@sentry/react-native';
// draft observation have 2 parts:
// 1. All the information, except processed photos are saved to persisted state.
// 2. Photos are processed (to be turned into a useable format by mapeo) async. Since promises cannot be stored in persisted storage, we hold those in a context. Once those photos are processed we save them to persisted state.
+/**
+ * @deprecated Use hooks from [draftObservation.ts](./draftObservation.ts)
+ */
export const useDraftObservation = () => {
const {addPhotoPromise, cancelPhotoProcessing, deletePhotoPromise} =
usePhotoPromiseContext();
diff --git a/src/frontend/screens/AddPhoto.tsx b/src/frontend/screens/AddPhoto.tsx
index 12b83b65e..8e3b11c18 100644
--- a/src/frontend/screens/AddPhoto.tsx
+++ b/src/frontend/screens/AddPhoto.tsx
@@ -5,9 +5,8 @@ import debug from 'debug';
import {defineMessages, FormattedMessage} from 'react-intl';
import {CameraView} from '../sharedComponents/CameraView';
-import {useDraftObservation} from '../hooks/useDraftObservation';
import {NativeRootNavigationProps} from '../sharedTypes/navigation';
-import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types';
+import {useDraftObservationActions} from '../hooks/draftObservation';
const m = defineMessages({
cancel: {
@@ -21,13 +20,7 @@ const log = debug('AddPhotoScreen');
export const AddPhotoScreen = ({
navigation,
}: NativeRootNavigationProps<'AddPhoto'>) => {
- const {addPhoto} = useDraftObservation();
-
- const handleAddPress = (capture: PhotoPromiseWithMetadata) => {
- log('pressed add button');
- addPhoto(capture);
- navigation.pop();
- };
+ const {addPhoto} = useDraftObservationActions();
const handleCancelPress = () => {
log('cancelled');
@@ -36,7 +29,13 @@ export const AddPhotoScreen = ({
return (
-
+ {
+ log('pressed add photo');
+ addPhoto(capturePromise, metadata);
+ navigation.pop();
+ }}
+ />
diff --git a/src/frontend/screens/CameraScreen.tsx b/src/frontend/screens/CameraScreen.tsx
index bdebb5958..530ebfd50 100644
--- a/src/frontend/screens/CameraScreen.tsx
+++ b/src/frontend/screens/CameraScreen.tsx
@@ -3,24 +3,26 @@ import {View, StyleSheet} from 'react-native';
import {useIsFocused} from '@react-navigation/native';
import {CameraView} from '../sharedComponents/CameraView';
-import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types';
-import {useDraftObservation} from '../hooks/useDraftObservation';
import {NativeHomeTabsNavigationProps} from '../sharedTypes/navigation';
+import {useDraftObservationActions} from '../hooks/draftObservation';
export const CameraScreen = ({
navigation,
}: NativeHomeTabsNavigationProps<'Camera'>) => {
const isFocused = useIsFocused();
- const {newDraft} = useDraftObservation();
-
- function handleAddPress(capture: PhotoPromiseWithMetadata) {
- newDraft(capture);
- navigation.navigate('PresetChooser');
- }
+ const {createDraft, addPhoto} = useDraftObservationActions();
return (
- {isFocused ? : null}
+ {isFocused ? (
+ {
+ createDraft();
+ addPhoto(capturePromise, metadata);
+ navigation.navigate('PresetChooser');
+ }}
+ />
+ ) : null}
);
};
diff --git a/src/frontend/sharedComponents/CameraView.tsx b/src/frontend/sharedComponents/CameraView.tsx
index 30a27e91d..e02244264 100644
--- a/src/frontend/sharedComponents/CameraView.tsx
+++ b/src/frontend/sharedComponents/CameraView.tsx
@@ -2,17 +2,16 @@ import React from 'react';
import {View, StyleSheet, Text} from 'react-native';
import {
Camera,
+ CameraCapturedPicture,
CameraPictureOptions,
CameraType,
- CameraCapturedPicture,
} from 'expo-camera';
-import ImageResizer from '@bam.tech/react-native-image-resizer';
import {Accelerometer, AccelerometerMeasurement} from 'expo-sensors';
import {AddButton} from './AddButton';
import {FormattedMessage, defineMessages} from 'react-intl';
import {Subscription} from 'expo-sensors/build/DeviceSensor';
-import {PhotoPromiseWithMetadata} from '../contexts/PhotoPromiseContext/types';
+import {PhotoMetadata} from '../hooks/persistedState/usePersistedDraftObservationNew';
import {useLocation} from '../hooks/useLocation';
const m = defineMessages({
@@ -26,21 +25,22 @@ const m = defineMessages({
},
});
-const CAPTURE_QUALITY = 75;
-
-const captureOptions: CameraPictureOptions = {
- base64: false,
- exif: true,
- skipProcessing: true,
-};
+function createCameraPictureOptions(): CameraPictureOptions {
+ return {
+ base64: false,
+ exif: true,
+ skipProcessing: true,
+ };
+}
type Props = {
- // Called when the user takes a picture.
- onAddPress: (capture: PhotoPromiseWithMetadata) => void;
+ onAddPress: (
+ capturePromise: Promise,
+ metadata: PhotoMetadata,
+ ) => void;
};
export const CameraView = ({onAddPress}: Props) => {
- const [capturing, setCapturing] = React.useState(false);
const [cameraReady, setCameraReady] = React.useState(false);
const ref = React.useRef(null);
const accelerometerMeasurement = React.useRef();
@@ -75,31 +75,14 @@ export const CameraView = ({onAddPress}: Props) => {
throw new Error('Camera Not Ready');
}
- // if there is a double click of the button => ignore
- if (capturing) {
- return;
- }
-
- setCapturing(true);
+ onAddPress(ref.current.takePictureAsync(createCameraPictureOptions()), {
+ accelerometer: accelerometerMeasurement.current,
+ location,
+ timestamp: Date.now(),
+ });
+ }, [onAddPress, location]);
- ref.current
- .takePictureAsync(captureOptions)
- .then(pic => {
- onAddPress({
- capturePromise: rotatePhoto(pic, accelerometerMeasurement.current),
- mediaMetadata: {location, timestamp: Date.now()},
- });
- })
- .catch(err => {
- console.log(err);
- setCapturing(false);
- })
- .finally(() => {
- setCapturing(false);
- });
- }, [capturing, setCapturing, onAddPress, location]);
-
- const disableButton = capturing || !cameraReady;
+ const disableButton = !cameraReady;
const permissionGranted = permissionsResponse?.status === 'granted';
return (
@@ -132,53 +115,6 @@ export const CameraView = ({onAddPress}: Props) => {
);
};
-function rotatePhoto(
- {uri, width, height}: CameraCapturedPicture,
- acc?: AccelerometerMeasurement,
-) {
- const resizePromise = ImageResizer.createResizedImage(
- uri,
- width,
- height,
- 'JPEG',
- CAPTURE_QUALITY,
- getPhotoRotation(acc),
- ).then(({uri}) => {
- return {uri};
- });
-
- return resizePromise;
-}
-
-const ACC_AT_45_DEG = Math.sin(Math.PI / 4);
-
-function getPhotoRotation(acc?: AccelerometerMeasurement) {
- if (!acc) return 0;
- const {x, y, z} = acc;
- let rotation = 0;
- if (z < -ACC_AT_45_DEG || z > ACC_AT_45_DEG) {
- // camera is pointing up or down
- if (Math.abs(y) > Math.abs(x)) {
- // camera is vertical
- if (y <= 0) rotation = 180;
- else rotation = 0;
- } else {
- // camera is horizontal
- if (x >= 0) rotation = -90;
- else rotation = 90;
- }
- } else if (x > -ACC_AT_45_DEG && x < ACC_AT_45_DEG) {
- // camera is vertical
- if (y <= 0) rotation = 180;
- else rotation = 0;
- } else {
- // camera is horizontal
- if (x >= 0) rotation = -90;
- else rotation = 90;
- }
- return rotation;
-}
-
const styles = StyleSheet.create({
container: {
flex: 1,