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,