Merge branch 'main' into obs-detail

This commit is contained in:
Angie Ta
2023-06-22 19:04:09 -07:00
21 changed files with 375 additions and 490 deletions

View File

@@ -60,7 +60,6 @@
00E356EE1AD99517003FC87E /* iNaturalistReactNativeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iNaturalistReactNativeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* iNaturalistReactNativeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = iNaturalistReactNativeTests.m; sourceTree = "<group>"; };
0FDA07B1F0D135536388A8A9 /* libPods-iNaturalistReactNative.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative.a"; sourceTree = BUILT_PRODUCTS_DIR; };
13B07F961A680F5B00A75B9A /* iNaturalistReactNative.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iNaturalistReactNative.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = iNaturalistReactNative/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = iNaturalistReactNative/AppDelegate.mm; sourceTree = "<group>"; };

14
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@babel/eslint-parser": "^7.21.3",
"@babel/preset-react": "^7.18.6",
"@bam.tech/react-native-image-resizer": "^3.0.5",
"@gorhom/bottom-sheet": "^4.4.6",
"@gorhom/bottom-sheet": "^4.4.7",
"@react-native-async-storage/async-storage": "^1.18.1",
"@react-native-camera-roll/camera-roll": "^5.4.0",
"@react-native-community/checkbox": "^0.5.15",
@@ -2209,9 +2209,9 @@
}
},
"node_modules/@gorhom/bottom-sheet": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.4.6.tgz",
"integrity": "sha512-okqJPtFQjfqPZdh6wGDzQKkMevG1IfplQeoWY0VqOFCp3E0p7WHNeW41voK7KXXCVTQaGXibPfd9GNGjXgFNyg==",
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.4.7.tgz",
"integrity": "sha512-ukTuTqDQi2heo68hAJsBpUQeEkdqP9REBcn47OpuvPKhdPuO1RBOOADjqXJNCnZZRcY+HqbnGPMSLFVc31zylQ==",
"dependencies": {
"@gorhom/portal": "1.0.14",
"invariant": "^2.2.4"
@@ -26893,9 +26893,9 @@
"integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ=="
},
"@gorhom/bottom-sheet": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.4.6.tgz",
"integrity": "sha512-okqJPtFQjfqPZdh6wGDzQKkMevG1IfplQeoWY0VqOFCp3E0p7WHNeW41voK7KXXCVTQaGXibPfd9GNGjXgFNyg==",
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-4.4.7.tgz",
"integrity": "sha512-ukTuTqDQi2heo68hAJsBpUQeEkdqP9REBcn47OpuvPKhdPuO1RBOOADjqXJNCnZZRcY+HqbnGPMSLFVc31zylQ==",
"requires": {
"@gorhom/portal": "1.0.14",
"invariant": "^2.2.4"

View File

@@ -29,7 +29,7 @@
"@babel/eslint-parser": "^7.21.3",
"@babel/preset-react": "^7.18.6",
"@bam.tech/react-native-image-resizer": "^3.0.5",
"@gorhom/bottom-sheet": "^4.4.6",
"@gorhom/bottom-sheet": "^4.4.7",
"@react-native-async-storage/async-storage": "^1.18.1",
"@react-native-camera-roll/camera-roll": "^5.4.0",
"@react-native-community/checkbox": "^0.5.15",

View File

@@ -20,7 +20,7 @@ type Props = {
currentUser: ?Object,
numObservations: number,
setHeightAboveToolbar: Function,
uploadStatus: Object,
allObsToUpload: Array<Object>,
setShowLoginSheet: Function
}
@@ -30,7 +30,7 @@ const Header = ( {
currentUser,
numObservations,
setHeightAboveToolbar,
uploadStatus,
allObsToUpload,
setShowLoginSheet
}: Props ): Node => {
const theme = useTheme( );
@@ -118,7 +118,7 @@ const Header = ( {
toggleLayout={toggleLayout}
layout={layout}
numUnuploadedObs={numUnuploadedObs}
uploadStatus={uploadStatus}
allObsToUpload={allObsToUpload}
setShowLoginSheet={setShowLoginSheet}
/>
)}

View File

@@ -26,7 +26,7 @@ type Props = {
observations: Array<Object>,
onEndReached: Function,
toggleLayout: Function,
uploadStatus: Object,
allObsToUpload: Array<Object>,
currentUser: ?Object,
showLoginSheet: boolean,
setShowLoginSheet: Function,
@@ -71,7 +71,7 @@ const MyObservations = ( {
observations,
onEndReached,
toggleLayout,
uploadStatus,
allObsToUpload,
currentUser,
showLoginSheet,
setShowLoginSheet
@@ -164,7 +164,7 @@ const MyObservations = ( {
observation={item}
layout={layout}
gridItemWidth={gridItemWidth}
uploadStatus={uploadStatus}
allObsToUpload={allObsToUpload}
setShowLoginSheet={setShowLoginSheet}
/>
);
@@ -215,12 +215,12 @@ const MyObservations = ( {
currentUser={currentUser}
numObservations={observations.length}
setHeightAboveToolbar={setHeightAboveToolbar}
uploadStatus={uploadStatus}
allObsToUpload={allObsToUpload}
setShowLoginSheet={setShowLoginSheet}
/>
<AnimatedFlashList
contentContainerStyle={contentContainerStyle}
data={observations}
data={observations.filter( o => o.isValid() )}
key={layout}
estimatedItemSize={
layout === "grid"

View File

@@ -7,15 +7,13 @@ import {
useCurrentUser,
useInfiniteScroll,
useLocalObservations,
useObservationsUpdates,
useUploadObservations
useObservationsUpdates
} from "sharedHooks";
import MyObservations from "./MyObservations";
const MyObservationsContainer = ( ): Node => {
const { observationList: observations, allObsToUpload } = useLocalObservations( );
const uploadStatus = useUploadObservations( allObsToUpload );
const { getItem, setItem } = useAsyncStorage( "myObservationsLayout" );
const [layout, setLayout] = useState( null );
const { isFetchingNextPage, fetchNextPage } = useInfiniteScroll( );
@@ -56,7 +54,7 @@ const MyObservationsContainer = ( ): Node => {
layout={layout}
toggleLayout={toggleLayout}
isFetchingNextPage={isFetchingNextPage}
uploadStatus={uploadStatus}
allObsToUpload={allObsToUpload}
currentUser={currentUser}
showLoginSheet={showLoginSheet}
setShowLoginSheet={setShowLoginSheet}

View File

@@ -32,12 +32,20 @@ const ObsUploadStatus = ( {
const theme = useTheme( );
const currentUser = useCurrentUser( );
const obsEditContext = useContext( ObsEditContext );
const startSingleUpload = obsEditContext?.startSingleUpload;
const uploadObservation = obsEditContext?.uploadObservation;
const uploadProgress = obsEditContext?.uploadProgress;
const whiteColor = white && theme.colors.onPrimary;
const isConnected = useIsConnected( );
const { t } = useTranslation( );
const needsSync = item => !item._synced_at
|| item._synced_at <= item._updated_at;
const totalProgressIncrements = needsSync( observation )
+ observation
.observationPhotos.map( obsPhoto => needsSync( obsPhoto ) ).length;
const currentProgress = uploadProgress?.[observation.uuid];
const displayUploadStatus = ( ) => {
const obsStatus = (
<ObsStatus
@@ -48,12 +56,12 @@ const ObsUploadStatus = ( {
/>
);
const progress = uploadProgress?.[observation.uuid];
if ( !observation.id || typeof progress === "number" ) {
if ( !observation.id || typeof currentProgress === "number" ) {
const progress = currentProgress / totalProgressIncrements;
return (
<UploadStatus
progress={progress || 0}
startSingleUpload={() => {
uploadObservation={() => {
if ( !isConnected ) {
Alert.alert(
t( "Internet-Connection-Required" ),
@@ -66,7 +74,7 @@ const ObsUploadStatus = ( {
setShowLoginSheet( true );
return;
}
startSingleUpload( observation );
uploadObservation( observation );
}}
color={whiteColor}
completeColor={whiteColor}

View File

@@ -3,11 +3,8 @@
import { useNavigation } from "@react-navigation/native";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useEffect } from "react";
import {
Alert,
Dimensions, PixelRatio
} from "react-native";
import React, { useContext, useEffect, useState } from "react";
import { Alert, Dimensions, PixelRatio } from "react-native";
import {
useCurrentUser,
useIsConnected,
@@ -21,39 +18,38 @@ type Props = {
toggleLayout: Function,
layout: string,
numUnuploadedObs: number,
uploadStatus: Object,
allObsToUpload: Array<Object>,
setShowLoginSheet: Function
}
const ToolbarContainer = ( {
toggleLayout, layout, numUnuploadedObs,
uploadStatus,
allObsToUpload,
setShowLoginSheet
}: Props ): Node => {
const { t } = useTranslation( );
const currentUser = useCurrentUser( );
const obsEditContext = useContext( ObsEditContext );
const syncObservations = obsEditContext?.syncObservations;
const stopUpload = obsEditContext?.stopUpload;
const setUploadProgress = obsEditContext?.setUploadProgress;
const uploadInProgress = obsEditContext?.uploadInProgress;
const uploadMultipleObservations = obsEditContext?.uploadMultipleObservations;
const currentUploadIndex = obsEditContext?.currentUploadIndex;
const progress = obsEditContext?.progress;
const setUploads = obsEditContext?.setUploads;
const uploads = obsEditContext?.uploads;
const uploadError = obsEditContext?.error;
const navigation = useNavigation( );
const isOnline = useIsConnected( );
const {
stopUpload,
uploadInProgress,
startUpload,
progress,
error: uploadError,
currentUploadIndex,
totalUploadCount
} = uploadStatus;
const uploadComplete = progress === 1;
const [totalUploadCount, setTotalUploadCount] = useState( allObsToUpload?.length || 0 );
const screenWidth = Dimensions.get( "window" ).width * PixelRatio.get();
const syncObservations = obsEditContext?.syncObservations;
const { refetch } = useObservationsUpdates( false );
const getStatusText = ( ) => {
if ( uploadComplete ) {
if ( progress === 1 ) {
return t( "X-observations-uploaded", { count: totalUploadCount } );
}
@@ -93,7 +89,8 @@ const ToolbarContainer = ( {
}
if ( numUnuploadedObs > 0 ) {
startUpload( );
setTotalUploadCount( allObsToUpload.length );
setUploads( allObsToUpload );
} else {
syncObservations( );
refetch( );
@@ -108,15 +105,22 @@ const ToolbarContainer = ( {
( numUnuploadedObs > 0 && !uploadInProgress ) || uploadError
);
useEffect( ( ) => {
if ( uploads?.length > 0 ) {
uploadMultipleObservations( );
}
}, [uploads, uploadMultipleObservations] );
// clear upload status when leaving screen
useEffect(
( ) => {
navigation.addListener( "blur", ( ) => {
uploadStatus.stopUpload( );
obsEditContext?.setUploadProgress( { } );
stopUpload( );
setUploadProgress( { } );
setTotalUploadCount( 0 );
} );
},
[navigation, uploadStatus, obsEditContext]
[navigation, setUploadProgress, stopUpload]
);
return (

View File

@@ -25,7 +25,9 @@ import { formatISO } from "date-fns";
import _ from "lodash";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import React, {
useCallback, useEffect, useMemo, useState
} from "react";
import { Alert, LogBox } from "react-native";
import {
ActivityIndicator,
@@ -279,6 +281,18 @@ const ObsDetails = (): Node => {
}
}, [observation, comments] );
const editButton = useMemo( ( ) => (
<IconButton
onPress={navToObsEdit}
icon="pencil"
textColor={colors.white}
className="absolute top-3 right-3"
accessible
accessibilityRole="button"
accessibilityLabel={t( "edit" )}
/>
), [navToObsEdit, t] );
if ( !observation ) {
return null;
}
@@ -381,15 +395,7 @@ const ObsDetails = (): Node => {
<View className="bg-black">
<PhotoScroll photos={photos} />
{/* TODO: a11y props are not passed down into this 3.party */}
<IconButton
onPress={navToObsEdit}
icon="pencil"
textColor={colors.white}
className="absolute top-3 right-3"
accessible
accessibilityRole="button"
accessibilityLabel={t( "edit" )}
/>
{ editButton }
{userFav
? (
<IconButton
@@ -430,6 +436,7 @@ const ObsDetails = (): Node => {
accessible
accessibilityLabel={t( "Observation-has-no-photos-and-no-sounds" )}
>
{ editButton }
<IconMaterial
color={colors.white}
testID="ObsDetails.noImage"

View File

@@ -10,7 +10,7 @@ import React, {
useContext,
useEffect, useState
} from "react";
import { FlatList } from "react-native";
import { ActivityIndicator, FlatList } from "react-native";
import colors from "styles/tailwindColors";
type Props = {
@@ -24,7 +24,8 @@ const EvidenceList = ( {
}: Props ): Node => {
const {
setMediaViewerUris,
setSelectedPhotoIndex
setSelectedPhotoIndex,
savingPhoto
} = useContext( ObsEditContext );
const navigation = useNavigation( );
const [deletePhotoMode, setDeletePhotoMode] = useState( false );
@@ -51,6 +52,19 @@ const EvidenceList = ( {
);
}
// add skeleton ActivityIndicator when a photo is being saved from the add evidence flow
if ( item === "savingPhoto" ) {
return (
<View className={classnames( imageClass )}>
<View className="rounded-lg overflow-hidden">
<View className="bg-lightGray w-fit h-full justify-center">
<ActivityIndicator />
</View>
</View>
</View>
);
}
return (
<Pressable
accessibilityRole="button"
@@ -75,6 +89,9 @@ const EvidenceList = ( {
const data = [...photoUris];
data.unshift( "add" );
if ( savingPhoto ) {
data.push( "savingPhoto" );
}
return (
<View className="mt-5">

View File

@@ -59,7 +59,6 @@ const Header = ( ): Node => {
const handleBackButtonPress = useCallback( ( ) => {
const unsyncedObservation = !currentObservation._synced_at && currentObservation._created_at;
console.log( currentObservation, "current observation" );
if ( params?.lastScreen === "GroupPhotos"
|| ( unsyncedObservation && !unsavedChanges )
) {

View File

@@ -70,6 +70,7 @@ const TextInputSheet = ( {
textAlignVertical: "top"
}}
autoFocus
defaultValue={input}
/>
<Body3
className="z-50 absolute bottom-20 right-5 p-5"

View File

@@ -18,7 +18,7 @@ type Props = {
color?: string,
completeColor?: string,
progress: number,
startSingleUpload: Function,
uploadObservation: Function,
layout: string,
children: any
}
@@ -43,7 +43,7 @@ const UploadStatus = ( {
color,
completeColor,
progress,
startSingleUpload,
uploadObservation,
layout,
children
}: Props ): Node => {
@@ -88,9 +88,9 @@ const UploadStatus = ( {
return t( "Upload-Complete" );
};
const startUpload = () => {
const startUpload = async ( ) => {
startAnimation();
startSingleUpload();
await uploadObservation( );
};
useEffect( () => () => cancelAnimation( rotation ), [rotation] );

View File

@@ -1,11 +1,11 @@
// @flow
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
import { useNavigation } from "@react-navigation/native";
import { activateKeepAwake, deactivateKeepAwake } from "@sayem314/react-native-keep-awake";
import {
activateKeepAwake,
deactivateKeepAwake
} from "@sayem314/react-native-keep-awake";
import { searchObservations } from "api/observations";
createObservation, createOrUpdateEvidence, searchObservations, updateObservation
} from "api/observations";
import inatjs from "inaturalistjs";
import type { Node } from "react";
import React, {
useCallback, useEffect,
@@ -15,10 +15,15 @@ import { EventRegister } from "react-native-event-listeners";
import Observation from "realmModels/Observation";
import ObservationPhoto from "realmModels/ObservationPhoto";
import Photo from "realmModels/Photo";
import emitUploadProgress, {
INCREMENT_SINGLE_UPLOAD_PROGRESS
} from "sharedHelpers/emitUploadProgress";
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
import { formatExifDateAsString, parseExif, writeExifToFile } from "sharedHelpers/parseExif";
import useApiToken from "sharedHooks/useApiToken";
import useCurrentUser from "sharedHooks/useCurrentUser";
import {
useApiToken,
useCurrentUser
} from "sharedHooks";
import { log } from "../../react-native-logs.config";
import { ObsEditContext, RealmContext } from "./contexts";
@@ -31,11 +36,14 @@ type Props = {
const logger = log.extend( "ObsEditProvider" );
const uploadProgressIncrement = 0.5;
const ObsEditProvider = ( { children }: Props ): Node => {
const navigation = useNavigation( );
const realm = useRealm( );
const apiToken = useApiToken( );
const currentUser = useCurrentUser( );
// state related to creating/editing an observation
const [currentObservationIndex, setCurrentObservationIndex] = useState( 0 );
const [observations, setObservations] = useState( [] );
const [cameraPreviewUris, setCameraPreviewUris] = useState( [] );
@@ -46,12 +54,24 @@ const ObsEditProvider = ( { children }: Props ): Node => {
const [album, setAlbum] = useState( null );
const [loading, setLoading] = useState( false );
const [unsavedChanges, setUnsavedChanges] = useState( false );
const [uploadProgress, setUploadProgress] = useState( { } );
const [passesEvidenceTest, setPassesEvidenceTest] = useState( false );
const [passesIdentificationTest, setPassesIdentificationTest] = useState( false );
const [mediaViewerUris, setMediaViewerUris] = useState( [] );
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState( 0 );
const [groupedPhotos, setGroupedPhotos] = useState( [] );
const [savingPhoto, setSavingPhoto] = useState( false );
// state related to uploads
const [uploadProgress, setUploadProgress] = useState( { } );
const [uploadInProgress, setUploadInProgress] = useState( false );
const [currentUploadIndex, setCurrentUploadIndex] = useState( 0 );
const [error, setError] = useState( null );
const [totalProgressIncrements, setTotalProgressIncrements] = useState( 0 );
const [totalUploadProgress, setTotalUploadProgress] = useState( 0 );
const [uploads, setUploads] = useState( [] );
const progress = totalProgressIncrements > 0
? totalUploadProgress / totalProgressIncrements
: 0;
const resetObsEditContext = useCallback( ( ) => {
setObservations( [] );
@@ -66,25 +86,33 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setGroupedPhotos( [] );
}, [] );
useEffect( () => {
const stopUpload = ( ) => {
setUploadInProgress( false );
setCurrentUploadIndex( 0 );
setError( null );
deactivateKeepAwake( );
setTotalProgressIncrements( 0 );
setTotalUploadProgress( 0 );
setUploads( [] );
};
useEffect( ( ) => {
const currentProgress = uploadProgress;
const progressListener = EventRegister.addEventListener(
"INCREMENT_OBSERVATIONS_PROGRESS",
INCREMENT_SINGLE_UPLOAD_PROGRESS,
increments => {
setUploadProgress( currentProgress => {
increments.forEach( ( [uuid, increment] ) => {
currentProgress[uuid] = currentProgress[uuid]
? currentProgress[uuid]
: 0;
currentProgress[uuid] += increment;
} );
return { ...currentProgress };
} );
const uuid = increments[0];
const increment = increments[1];
currentProgress[uuid] = ( uploadProgress[uuid] || 0 ) + increment;
setTotalUploadProgress( totalUploadProgress + increment );
setUploadProgress( currentProgress );
}
);
return () => {
return ( ) => {
EventRegister.removeEventListener( progressListener );
};
}, [] );
}, [uploadProgress, totalUploadProgress] );
const allObsPhotoUris = useMemo(
( ) => [...cameraPreviewUris, ...galleryUris],
@@ -165,8 +193,10 @@ const ObsEditProvider = ( { children }: Props ): Node => {
}, [currentObservation] );
const addGalleryPhotosToCurrentObservation = useCallback( async photos => {
setSavingPhoto( true );
const obsPhotos = await createObsPhotos( photos );
appendObsPhotos( obsPhotos );
setSavingPhoto( false );
}, [createObsPhotos, appendObsPhotos] );
const uploadValue = useMemo( ( ) => {
@@ -217,6 +247,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
};
const addCameraPhotosToCurrentObservation = async localFilePaths => {
setSavingPhoto( true );
const obsPhotos = await Promise.all( localFilePaths.map(
async photo => ObservationPhoto.new( photo )
) );
@@ -226,6 +257,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
localFilePaths
);
await savePhotosToCameraGallery( localFilePaths );
setSavingPhoto( false );
};
const updateObservationKeys = keysAndValues => {
@@ -301,7 +333,83 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setLoading( false );
};
const uploadObservation = async observation => {
const markRecordUploaded = ( recordUUID, type, response ) => {
if ( !response ) { return; }
const { id } = response.results[0];
const record = realm.objectForPrimaryKey( type, recordUUID );
realm?.write( ( ) => {
record.id = id;
record._synced_at = new Date( );
} );
};
const uploadToServer = async (
evidenceUUID: string,
type: string,
params: Object,
apiEndpoint: Function,
options: Object,
observationUUID?: string
) => {
emitUploadProgress( observationUUID, uploadProgressIncrement );
const response = await createOrUpdateEvidence(
apiEndpoint,
params,
options
);
if ( response ) {
emitUploadProgress( observationUUID, uploadProgressIncrement );
markRecordUploaded( evidenceUUID, type, response );
}
};
const uploadEvidence = async (
evidence: Array<Object>,
type: string,
apiSchemaMapper: Function,
observationId: ?number,
apiEndpoint: Function,
options: Object,
observationUUID?: string,
forceUpload?: boolean
): Promise<any> => {
// only try to upload evidence which is not yet on the server
const unsyncedEvidence = forceUpload
? evidence
: evidence.filter( item => !item.wasSynced( ) );
const responses = await Promise.all( unsyncedEvidence.map( item => {
const currentEvidence = item.toJSON( );
const evidenceUUID = currentEvidence.uuid;
// Remove all null values, b/c the API doesn't seem to like them
const newPhoto = {};
const photo = currentEvidence?.photo;
Object.keys( photo ).forEach( k => {
if ( photo[k] !== null ) {
newPhoto[k] = photo[k];
}
} );
currentEvidence.photo = newPhoto;
const params = apiSchemaMapper( observationId, currentEvidence );
return uploadToServer(
evidenceUUID,
type,
params,
apiEndpoint,
options,
observationUUID
);
} ) );
// eslint-disable-next-line consistent-return
return responses[0];
};
const uploadObservation = async obs => {
setLoading( true );
// don't bother trying to upload unless there's a logged in user
if ( !currentUser ) { return {}; }
if ( !apiToken ) {
@@ -309,13 +417,83 @@ const ObsEditProvider = ( { children }: Props ): Node => {
"Gack, tried to upload an observation without API token!"
);
}
activateKeepAwake();
const response = Observation.uploadObservation(
observation,
apiToken,
realm
);
deactivateKeepAwake();
activateKeepAwake( );
// every observation and observation photo counts for a total of 1 progress
// we're showing progress in 0.5 increments: when an upload of obs/obsPhoto starts
// and when the upload of obs/obsPhoto successfully completes
emitUploadProgress( obs.uuid, uploadProgressIncrement );
const obsToUpload = Observation.mapObservationForUpload( obs );
const options = { api_token: apiToken };
// Remove all null values, b/c the API doesn't seem to like them for some
// reason (might be an error with the API as of 20220801)
const newObs = {};
Object.keys( obsToUpload ).forEach( k => {
if ( obsToUpload[k] !== null ) {
newObs[k] = obsToUpload[k];
}
} );
let response;
// First upload the photos/sounds (before uploading the observation itself)
const hasPhotos = obs?.observationPhotos?.length > 0;
await Promise.all( [
hasPhotos
? uploadEvidence(
obs.observationPhotos,
"ObservationPhoto",
ObservationPhoto.mapPhotoForUpload,
null,
inatjs.photos.create,
options
)
: null
] );
const wasPreviouslySynced = obs.wasSynced( );
const uploadParams = {
observation: { ...newObs },
fields: { id: true }
};
if ( wasPreviouslySynced ) {
response = await updateObservation( {
...uploadParams,
id: newObs.uuid,
ignore_photos: true
}, options );
emitUploadProgress( obs.uuid, uploadProgressIncrement );
} else {
response = await createObservation( uploadParams, options );
emitUploadProgress( obs.uuid, uploadProgressIncrement );
}
if ( !response ) {
return response;
}
const { uuid: obsUUID } = response.results[0];
await Promise.all( [
markRecordUploaded( obs.uuid, "Observation", response ),
// Next, attach the uploaded photos/sounds to the uploaded observation
hasPhotos
? uploadEvidence(
obs.observationPhotos,
"ObservationPhoto",
ObservationPhoto.mapPhotoForAttachingToObs,
obsUUID,
inatjs.observation_photos.create,
options,
obsUUID,
true
)
: null
] );
deactivateKeepAwake( );
setLoading( false );
return response;
};
@@ -368,31 +546,6 @@ const ObsEditProvider = ( { children }: Props ): Node => {
await Photo.deletePhoto( realm, photoUriToDelete );
};
const startSingleUpload = async observation => {
setLoading( true );
const { uuid } = observation;
setUploadProgress( {
...uploadProgress,
[uuid]: 0.5
} );
const response = await uploadObservation( observation );
if ( Object.keys( response ).length === 0 ) {
return;
}
// TODO: mostly making sure UI presentation works at the moment, but we will
// need to figure out what counts as progress towards an observation uploading
// and add that functionality.
// maybe uploading an observation is 0.33, starting to upload photos is 0.5,
// checking for sounds is 0.66 progress?
// and we need a way to track this progress from the Observation.uploadObservation function
setLoading( false );
setUploadProgress( {
...uploadProgress,
[uuid]: 1
} );
};
const downloadRemoteObservationsFromServer = async ( ) => {
const params = {
user_id: currentUser?.id,
@@ -415,6 +568,38 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setLoading( false );
};
const uploadMultipleObservations = ( ) => {
if ( totalProgressIncrements === 0 ) {
setTotalProgressIncrements( uploads.length + uploads
.reduce( ( count, current ) => count
+ current.observationPhotos.length, 0 ) );
}
const upload = async observationToUpload => {
try {
await uploadObservation( observationToUpload );
} catch ( e ) {
console.warn( e );
setError( e.message );
}
setCurrentUploadIndex( currentIndex => currentIndex + 1 );
};
const observationToUpload = uploads[currentUploadIndex];
const continueUpload = observationToUpload && !!apiToken;
if ( !continueUpload ) {
setUploadInProgress( false );
return;
}
const uploadedUUIDS = Object.keys( uploadProgress );
// only try to upload every observation once
if ( !uploadedUUIDS.includes( observationToUpload.uuid ) ) {
setUploadInProgress( true );
upload( observationToUpload );
}
};
return {
createObservationNoEvidence,
addObservations,
@@ -450,7 +635,6 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setLoading,
unsavedChanges,
syncObservations,
startSingleUpload,
uploadProgress,
setUploadProgress,
saveAllObservations,
@@ -464,8 +648,17 @@ const ObsEditProvider = ( { children }: Props ): Node => {
setSelectedPhotoIndex,
groupedPhotos,
setGroupedPhotos,
stopUpload,
uploadMultipleObservations,
uploadInProgress,
error,
currentUploadIndex,
progress,
setUploads,
uploads,
originalCameraUrisMap,
setOriginalCameraUrisMap
setOriginalCameraUrisMap,
savingPhoto
};
}, [
currentObservation,
@@ -495,10 +688,17 @@ const ObsEditProvider = ( { children }: Props ): Node => {
mediaViewerUris,
selectedPhotoIndex,
groupedPhotos,
currentUploadIndex,
error,
uploadInProgress,
progress,
totalProgressIncrements,
uploads,
setOriginalCameraUrisMap,
originalCameraUrisMap,
appendObsPhotos,
cameraRollUris
cameraRollUris,
savingPhoto
] );
return (

View File

@@ -1,8 +1,4 @@
import { Realm } from "@realm/react";
// eslint-disable-next-line import/no-cycle
import { createObservation, createOrUpdateEvidence, updateObservation } from "api/observations";
import inatjs from "inaturalistjs";
import { EventRegister } from "react-native-event-listeners";
import uuid from "react-native-uuid";
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
@@ -246,231 +242,6 @@ class Observation extends Realm.Object {
return unsyncedObs.length > 0;
};
static markRecordUploaded = async ( recordUUID, type, response, realm ) => {
const { id } = response.results[0];
const record = realm.objectForPrimaryKey( type, recordUUID );
realm?.write( ( ) => {
record.id = id;
record._synced_at = new Date( );
} );
};
static uploadToServer = async (
evidenceUUID: string,
type: string,
params: Object,
apiEndpoint: Function,
realm: any,
options: Object
) => {
const response = await createOrUpdateEvidence( apiEndpoint, params, options );
if ( response ) {
await Observation.markRecordUploaded( evidenceUUID, type, response, realm );
}
};
static uploadEvidence = async (
evidence: Array<Object>,
type: string,
apiSchemaMapper: Function,
observationId: number,
apiEndpoint: Function,
realm: any,
options: Object,
forceUpload: boolean
): Promise<any> => {
// only try to upload evidence which is not yet on the server
const unsyncedEvidence = forceUpload
? evidence
: evidence.filter( item => !item.wasSynced( ) );
const responses = await Promise.all( unsyncedEvidence.map( item => {
const currentEvidence = item.toJSON( );
const evidenceUUID = currentEvidence.uuid;
// Remove all null values, b/c the API doesn't seem to like them
const newPhoto = {};
const photo = currentEvidence?.photo;
Object.keys( photo ).forEach( k => {
if ( photo[k] !== null ) {
newPhoto[k] = photo[k];
}
} );
currentEvidence.photo = newPhoto;
const params = apiSchemaMapper( observationId, currentEvidence );
return Observation.uploadToServer(
evidenceUUID,
type,
params,
apiEndpoint,
realm,
options
);
} ) );
// eslint-disable-next-line consistent-return
return responses[0];
};
static uploadObservation = async ( obs, apiToken, realm ) => {
try {
EventRegister.emit(
"INCREMENT_OBSERVATIONS_PROGRESS",
[[obs.uuid, 0.05]]
);
const obsToUpload = Observation.mapObservationForUpload( obs );
const options = { api_token: apiToken };
// Remove all null values, b/c the API doesn't seem to like them for some
// reason (might be an error with the API as of 20220801)
const newObs = {};
Object.keys( obsToUpload ).forEach( k => {
if ( obsToUpload[k] !== null ) {
newObs[k] = obsToUpload[k];
}
} );
const uploadParams = {
observation: { ...newObs },
fields: { id: true }
};
let response;
// First upload the photos/sounds (before uploading the observation itself)
const hasPhotos = obs?.observationPhotos?.length > 0;
const hasSounds = obs?.observationSounds?.length > 0;
await Promise.all( [
hasPhotos
? Observation.uploadEvidence(
obs.observationPhotos,
"ObservationPhoto",
ObservationPhoto.mapPhotoForUpload,
null,
inatjs.photos.create,
realm,
options
).then( () => {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
hasSounds
? 0.125
: 0.25
]] );
} )
: null,
hasSounds
? Observation.uploadEvidence(
obs.observationSounds,
"ObservationSound",
ObservationSound.mapSoundForUpload,
null,
inatjs.sounds.create,
realm,
options
).then( () => {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
hasPhotos
? 0.125
: 0.25
]] );
} )
: null
] );
if ( !hasPhotos && !hasSounds ) {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
0.25
]] );
}
// TODO
const wasPreviouslySynced = obs.wasSynced( );
if ( wasPreviouslySynced ) {
response = await updateObservation( {
id: newObs.uuid,
ignore_photos: true,
observation: { ...newObs },
fields: { id: true }
}, options );
} else {
// TODO - before creating observation, POST /v2/photos or POST /v2/sounds
response = await createObservation( uploadParams, options );
}
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
0.3
]] );
const { uuid: obsUUID } = response.results[0];
await Promise.all( [
Observation.markRecordUploaded( obs.uuid, "Observation", response, realm ),
// Next, attach the uploaded photos/sounds to the uploaded observation
hasPhotos
? Observation.uploadEvidence(
obs.observationPhotos,
"ObservationPhoto",
ObservationPhoto.mapPhotoForAttachingToObs,
obsUUID,
inatjs.observation_photos.create,
realm,
options,
true
).then( () => {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
hasSounds
? 0.2
: 0.4
]] );
} )
: null,
hasSounds
? Observation.uploadEvidence(
obs.observationSounds,
"ObservationSound",
ObservationSound.mapSoundForAttachingToObs,
obsUUID,
inatjs.observation_sounds.create,
realm,
options,
true
).then( () => {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
hasPhotos
? 0.2
: 0.4
]] );
} )
: null
] );
if ( !hasPhotos && !hasSounds ) {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
0.4
]] );
}
return response;
} catch ( error ) {
EventRegister.emit( "INCREMENT_OBSERVATIONS_PROGRESS", [[
obs.uuid,
-1
]] );
throw error;
}
};
static schema = {
name: "Observation",
primaryKey: "uuid",

View File

@@ -0,0 +1,13 @@
import { EventRegister } from "react-native-event-listeners";
export const INCREMENT_SINGLE_UPLOAD_PROGRESS = "singleUploadProgress";
const emitUploadProgress = ( observationUUID, increment ) => {
if ( !observationUUID ) { return; }
EventRegister.emit(
INCREMENT_SINGLE_UPLOAD_PROGRESS,
[observationUUID, increment]
);
};
export default emitUploadProgress;

View File

@@ -16,6 +16,5 @@ export { default as useObservationUpdatesWhenFocused } from "./useObservationUpd
export { default as useShare } from "./useShare";
export { default as useTranslation } from "./useTranslation";
export { default as useUnlockScreen } from "./useUnlockScreen";
export { default as useUploadObservations } from "./useUploadObservations";
export { default as useUserLocation } from "./useUserLocation";
export { default as useUserMe } from "./useUserMe";

View File

@@ -18,7 +18,8 @@ const useInfiniteScroll = ( ): Object => {
const baseParams = {
user_id: currentUser?.id,
per_page: 50,
fields: Observation.FIELDS
fields: Observation.FIELDS,
ttl: -1
};
const {

View File

@@ -16,7 +16,6 @@ const useLocalObservations = ( ): Object => {
// when they have lost focus, which prevents other
// views from rendering when they have focus.
const stagedObservationList = useRef( [] );
const stagedObsToUpload = useRef( [] );
const [observationList, setObservationList] = useState( [] );
const [allObsToUpload, setAllObsToUpload] = useState( [] );
@@ -29,23 +28,13 @@ const useLocalObservations = ( ): Object => {
const obs = realm.objects( "Observation" );
const localObservations = obs.sorted( "_created_at", true );
localObservations.addListener( ( collection, _changes ) => {
if ( localObservations.length === 0 ) { return; }
// started hitting https://github.com/realm/realm-js/issues/4484 on
// 2022-09-13 for no reason i can discern Note that if you
// setObservationsList to collection, it is a Realm.Collection, not an
// array, which doesn't seem to work. _.compact or Array.from will
// create an array of Realm objects... which will probably require some
// degree of pagination in the future
// setObservationList( _.compact( collection ) );
stagedObservationList.current = [...collection];
const unsyncedObs = Observation.filterUnsyncedObservations( realm );
stagedObsToUpload.current = Array.from( unsyncedObs );
if ( isFocused ) {
setObservationList( stagedObservationList.current );
setAllObsToUpload( stagedObsToUpload.current );
setAllObsToUpload( Array.from( unsyncedObs ) );
}
} );
// eslint-disable-next-line consistent-return
@@ -58,7 +47,6 @@ const useLocalObservations = ( ): Object => {
useEffect( ( ) => {
if ( isFocused ) {
setObservationList( stagedObservationList.current );
setAllObsToUpload( stagedObsToUpload.current );
}
}, [isFocused] );

View File

@@ -1,122 +0,0 @@
// @flow
import {
activateKeepAwake,
deactivateKeepAwake
} from "@sayem314/react-native-keep-awake";
import { RealmContext } from "providers/contexts";
import { useEffect, useState } from "react";
import { EventRegister } from "react-native-event-listeners";
import Observation from "realmModels/Observation";
import useApiToken from "sharedHooks/useApiToken";
const { useRealm } = RealmContext;
const useUploadObservations = ( allObsToUpload: Array<Object> ): Object => {
const [uploadInProgress, setUploadInProgress] = useState( false );
const [shouldUpload, setShouldUpload] = useState( false );
const [currentUploadIndex, setCurrentUploadIndex] = useState( 0 );
const [progress, setProgress] = useState( 0 );
const [error, setError] = useState( null );
const realm = useRealm( );
const apiToken = useApiToken( );
const [totalUploadCount, setTotalUploadCount] = useState( 0 );
const cleanup = ( ) => {
setUploadInProgress( false );
setShouldUpload( false );
setCurrentUploadIndex( 0 );
setError( null );
deactivateKeepAwake( );
setProgress( 0 );
setTotalUploadCount( 0 );
};
useEffect( ( ) => {
if ( shouldUpload ) {
EventRegister.emit(
"INCREMENT_OBSERVATIONS_PROGRESS",
allObsToUpload.map( observation => [observation.uuid, 0] )
);
}
}, [shouldUpload, allObsToUpload] );
useEffect( ( ) => {
const upload = async observationToUpload => {
const increment = ( 1 / allObsToUpload.length ) / 2;
setProgress( currentProgress => currentProgress + increment );
try {
await Observation.uploadObservation(
observationToUpload,
apiToken,
realm
);
} catch ( e ) {
console.warn( e );
setError( e.message );
}
setProgress( currentProgress => {
if ( currentUploadIndex === allObsToUpload.length - 1 ) {
return 1;
}
return currentProgress + increment;
} );
setCurrentUploadIndex( currentIndex => currentIndex + 1 );
};
const observationToUpload = allObsToUpload[currentUploadIndex];
const continueUpload = shouldUpload && observationToUpload && !!apiToken;
if ( !continueUpload ) {
return;
}
setTotalUploadCount( allObsToUpload.length );
activateKeepAwake( );
setUploadInProgress( true );
upload( observationToUpload );
}, [
allObsToUpload,
apiToken,
shouldUpload,
currentUploadIndex,
realm
] );
// // Fake upload in progress
// return {
// uploadInProgress: true,
// error: null,
// progress: 0.5,
// stopUpload: cleanup,
// currentUploadIndex: 0,
// totalUploadCount: 1,
// startUpload: ( ) => setShouldUpload( true ),
// allObsToUpload: [{}, {}, {}, {}]
// };
// // Fake error state
// return {
// uploadInProgress: false,
// error: "Something went terribly wrong",
// progress: 0,
// stopUpload: cleanup,
// currentUploadIndex: 0,
// totalUploadCount: 1,
// startUpload: ( ) => setShouldUpload( true ),
// allObsToUpload: [{},{},{},{}]
// };
return {
uploadInProgress,
error,
progress,
stopUpload: cleanup,
currentUploadIndex,
totalUploadCount,
startUpload: ( ) => setShouldUpload( true ),
allObsToUpload
};
};
export default useUploadObservations;

View File

@@ -25,5 +25,7 @@ export default define( "LocalObservation", faker => ( {
// is this the right way to test this?
needsSync: jest.fn( ),
wasSynced: jest.fn( ),
observed_on_string: "2022-12-03T11:14:16"
observed_on_string: "2022-12-03T11:14:16",
// This is a Realm object method that we use to see if a record was deleted or not
isValid: jest.fn( () => true )
} ) );