mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
Merge branch 'main' into 250-e2e-init
This commit is contained in:
@@ -7,12 +7,17 @@ module.exports = {
|
||||
presets: ["@babel/preset-react"]
|
||||
}
|
||||
},
|
||||
extends: ["airbnb", "plugin:i18next/recommended"],
|
||||
extends: [
|
||||
"airbnb",
|
||||
"plugin:i18next/recommended",
|
||||
"plugin:@tanstack/eslint-plugin-query/recommended"
|
||||
],
|
||||
plugins: [
|
||||
"module-resolver",
|
||||
"react-hooks",
|
||||
"react-native",
|
||||
"simple-import-sort"
|
||||
"simple-import-sort",
|
||||
"@tanstack/query"
|
||||
],
|
||||
globals: {
|
||||
FormData: true
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -77,3 +77,6 @@ fastlane/Appfile
|
||||
# Detox e2e test artifacts
|
||||
artifacts/
|
||||
*.log
|
||||
|
||||
# VisualStudioCode #
|
||||
.vscode
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.tabSize": 2,
|
||||
"eslint.validate": ["javascript"]
|
||||
}
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@@ -30,7 +30,7 @@
|
||||
"axios": "^0.25.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"date-fns": "^2.28.0",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"date-fns-tz": "^1.3.7",
|
||||
"fbjs": "^3.0.4",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-fluent": "^2.0.0",
|
||||
@@ -87,6 +87,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@tanstack/eslint-plugin-query": "^4.20.8",
|
||||
"@testing-library/react-native": "^10.0.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
@@ -4709,6 +4710,16 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/eslint-plugin-query": {
|
||||
"version": "4.20.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-4.20.8.tgz",
|
||||
"integrity": "sha512-JnAxUAC9DNmL9fwcfY3UjeFjjQwzV7cmPtf/Iac2o/p1Pmavul/L5bjo95zQNsBiBtJddHg+M17ximjpRybWJg==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "4.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.10.3.tgz",
|
||||
@@ -19573,9 +19584,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
|
||||
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
|
||||
"version": "4.9.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
@@ -23603,6 +23614,12 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"@tanstack/eslint-plugin-query": {
|
||||
"version": "4.20.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-4.20.8.tgz",
|
||||
"integrity": "sha512-JnAxUAC9DNmL9fwcfY3UjeFjjQwzV7cmPtf/Iac2o/p1Pmavul/L5bjo95zQNsBiBtJddHg+M17ximjpRybWJg==",
|
||||
"dev": true
|
||||
},
|
||||
"@tanstack/query-core": {
|
||||
"version": "4.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.10.3.tgz",
|
||||
@@ -34952,9 +34969,9 @@
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz",
|
||||
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
|
||||
"version": "4.9.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
|
||||
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"android": "react-native run-android",
|
||||
"ios": "react-native run-ios",
|
||||
"start": "react-native start",
|
||||
"clean-start": "npx react-native clean-project-auto && npx pod-install && npm start",
|
||||
"test": "jest",
|
||||
"lint": "npm run lint:eslint && npm run lint:flow",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
@@ -40,7 +41,7 @@
|
||||
"axios": "^0.25.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"date-fns": "^2.28.0",
|
||||
"date-fns-tz": "^1.3.3",
|
||||
"date-fns-tz": "^1.3.7",
|
||||
"fbjs": "^3.0.4",
|
||||
"i18next": "^21.6.14",
|
||||
"i18next-fluent": "^2.0.0",
|
||||
@@ -97,6 +98,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@tanstack/eslint-plugin-query": "^4.20.8",
|
||||
"@testing-library/react-native": "^10.0.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-plugin-module-resolver": "^4.1.0",
|
||||
|
||||
@@ -20,9 +20,11 @@ Object.defineProperty( INatApiError.prototype, "name", {
|
||||
|
||||
const handleError = async ( e: Object, options: Object = {} ): Object => {
|
||||
if ( !e.response ) { throw e; }
|
||||
const errorJson = await e.response.json( );
|
||||
const error = new INatApiError( errorJson );
|
||||
console.error( `Error requesting ${e.response.url}` );
|
||||
const errorText = await e.response.text( );
|
||||
const error = new INatApiError( errorText );
|
||||
console.error(
|
||||
`Error requesting ${e.response.url} (status: ${e.response.status}): ${errorText}`
|
||||
);
|
||||
if ( options.throw ) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const searchMessages = async ( params: Object = {}, opts: Object = {} ): Promise
|
||||
const { results } = await inatjs.messages.search( { ...PARAMS, ...params }, opts );
|
||||
return results;
|
||||
} catch ( e ) {
|
||||
return handleError( e );
|
||||
return handleError( e, { throw: true } );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,81 +1,12 @@
|
||||
// @flow
|
||||
|
||||
import inatjs from "inaturalistjs";
|
||||
import Comment from "realmModels/Comment";
|
||||
import Identification from "realmModels/Identification";
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import Observation from "realmModels/Observation";
|
||||
import User from "realmModels/User";
|
||||
|
||||
import handleError from "./error";
|
||||
|
||||
const PHOTO_FIELDS = {
|
||||
id: true,
|
||||
attribution: true,
|
||||
license_code: true,
|
||||
url: true
|
||||
};
|
||||
|
||||
const OBSERVATION_PHOTOS_FIELDS = {
|
||||
id: true,
|
||||
photo: PHOTO_FIELDS,
|
||||
position: true,
|
||||
uuid: true
|
||||
};
|
||||
|
||||
const TAXON_FIELDS = {
|
||||
name: true,
|
||||
preferred_common_name: true
|
||||
};
|
||||
|
||||
const FIELDS = {
|
||||
observation_photos: OBSERVATION_PHOTOS_FIELDS,
|
||||
taxon: TAXON_FIELDS
|
||||
};
|
||||
|
||||
const PARAMS = {
|
||||
per_page: 10,
|
||||
fields: FIELDS
|
||||
};
|
||||
|
||||
const REMOTE_OBSERVATION_PARAMS = {
|
||||
fields: {
|
||||
created_at: true,
|
||||
uuid: true,
|
||||
identifications: Identification.ID_FIELDS,
|
||||
comments: Comment.COMMENT_FIELDS,
|
||||
category: true,
|
||||
updated_at: true,
|
||||
observation_photos: OBSERVATION_PHOTOS_FIELDS,
|
||||
taxon: {
|
||||
default_photo: {
|
||||
url: true,
|
||||
attribution: true,
|
||||
license_code: true
|
||||
},
|
||||
iconic_taxon_name: true,
|
||||
name: true,
|
||||
preferred_common_name: true,
|
||||
rank: true,
|
||||
rank_level: true
|
||||
},
|
||||
observed_on_string: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
description: true,
|
||||
application: {
|
||||
name: true
|
||||
},
|
||||
place_guess: true,
|
||||
quality_grade: true,
|
||||
time_observed_at: true,
|
||||
user: User.USER_FIELDS
|
||||
}
|
||||
};
|
||||
|
||||
const searchObservations = async ( params: Object = {}, opts: Object = {} ): Promise<any> => {
|
||||
try {
|
||||
const { results } = await inatjs.observations.search( { ...PARAMS, ...params }, opts );
|
||||
const { results } = await inatjs.observations.search( params, opts );
|
||||
return results || [];
|
||||
} catch ( e ) {
|
||||
return handleError( e, { throw: true } );
|
||||
@@ -106,11 +37,11 @@ const fetchRemoteObservation = async (
|
||||
try {
|
||||
const { results } = await inatjs.observations.fetch(
|
||||
uuid,
|
||||
{ ...REMOTE_OBSERVATION_PARAMS, ...params },
|
||||
params,
|
||||
opts
|
||||
);
|
||||
if ( results?.length > 0 ) {
|
||||
return Observation.mimicRealmMappedPropertiesSchema( results[0] );
|
||||
return results[0];
|
||||
}
|
||||
return null;
|
||||
} catch ( e ) {
|
||||
@@ -155,7 +86,6 @@ const updateObservation = async (
|
||||
try {
|
||||
return await inatjs.observations.update( params, opts );
|
||||
} catch ( e ) {
|
||||
console.log( e, "error in update obs" );
|
||||
return handleError( e );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ const fetchUserMe = async ( params: Object = {}, opts: Object = {} ): Promise<an
|
||||
const { results } = await inatjs.users.me( { ...PARAMS, ...params, ...opts } );
|
||||
return results[0];
|
||||
} catch ( e ) {
|
||||
return handleError( e );
|
||||
return handleError( e, { throw: true } );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
closeModal: ( ) => void
|
||||
}
|
||||
|
||||
const CameraOptionsModal = ( { closeModal }: Props ): React.Node => {
|
||||
const AddObsModal = ( { closeModal }: Props ): React.Node => {
|
||||
// Destructuring obsEdit means that we don't have to wrap every Jest test in ObsEditProvider
|
||||
const obsEditContext = React.useContext( ObsEditContext );
|
||||
const createObservationNoEvidence = obsEditContext?.createObservationNoEvidence;
|
||||
@@ -80,4 +80,4 @@ const CameraOptionsModal = ( { closeModal }: Props ): React.Node => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraOptionsModal;
|
||||
export default AddObsModal;
|
||||
@@ -1,15 +1,14 @@
|
||||
// @flow
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { fetchUserMe } from "api/users";
|
||||
import { getUserId, signOut } from "components/LoginSignUp/AuthenticationService";
|
||||
import i18next from "i18next";
|
||||
import RootDrawerNavigator from "navigation/rootDrawerNavigation";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useUserMe from "sharedHooks/useUserMe";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
@@ -25,16 +24,7 @@ const App = ( { children }: Props ): Node => {
|
||||
|
||||
// fetch current user from server and save to realm in useEffect
|
||||
// this is used for changing locale and also for showing UserCard
|
||||
const {
|
||||
data: remoteUser
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchUserMe"],
|
||||
optsWithAuth => fetchUserMe( { }, optsWithAuth ),
|
||||
{},
|
||||
{
|
||||
enabled: !!currentUser
|
||||
}
|
||||
);
|
||||
const { remoteUser } = useUserMe( );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const checkForSignedInUser = async ( ) => {
|
||||
@@ -71,10 +61,11 @@ const App = ( { children }: Props ): Node => {
|
||||
async function changeLanguageToLocale( locale ) {
|
||||
await i18next.changeLanguage( locale );
|
||||
}
|
||||
if ( !currentUser ) { return; }
|
||||
if ( currentUser?.locale ) {
|
||||
changeLanguageToLocale( currentUser.locale );
|
||||
}
|
||||
}, [currentUser?.locale] );
|
||||
}, [currentUser] );
|
||||
|
||||
// this children prop is here for the sake of testing with jest
|
||||
// normally we would never do this in code
|
||||
|
||||
@@ -85,13 +85,9 @@ const CameraView = ( { camera, device }: Props ): Node => {
|
||||
}, [neutralZoom, zoom] );
|
||||
|
||||
const tapToFocus = async ( { nativeEvent } ) => {
|
||||
try {
|
||||
await camera.current.focus( { x: nativeEvent.x, y: nativeEvent.y } );
|
||||
tapToFocusAnimation.setValue( 1 );
|
||||
setTappedCoordinates( nativeEvent );
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't tap to focus" );
|
||||
}
|
||||
await camera.current.focus( { x: nativeEvent.x, y: nativeEvent.y } );
|
||||
tapToFocusAnimation.setValue( 1 );
|
||||
setTappedCoordinates( nativeEvent );
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t } from "i18next";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useContext, useRef, useState } from "react";
|
||||
import { StatusBar } from "react-native";
|
||||
import { StatusBar, Platform } from "react-native";
|
||||
import { Avatar, Snackbar } from "react-native-paper";
|
||||
import Icon from "react-native-vector-icons/MaterialIcons";
|
||||
import { Camera, useCameraDevices } from "react-native-vision-camera";
|
||||
@@ -21,10 +21,31 @@ import PhotoPreview from "./PhotoPreview";
|
||||
|
||||
export const MAX_PHOTOS_ALLOWED = 20;
|
||||
|
||||
// Taken from:
|
||||
// https://developer.android.com/reference/androidx/exifinterface/media/ExifInterface#ORIENTATION_ROTATE_180()
|
||||
const ORIENTATION_ROTATE_90 = 6;
|
||||
const ORIENTATION_ROTATE_180 = 3;
|
||||
const ORIENTATION_ROTATE_270 = 8;
|
||||
|
||||
// Calculates by how much we should rotate our image according to the detected orientation
|
||||
const orientationToRotation = orientation => {
|
||||
// This issue only occurs on Android
|
||||
if ( Platform.OS !== "android" ) return 0;
|
||||
|
||||
if ( orientation === ORIENTATION_ROTATE_90 ) return 90;
|
||||
if ( orientation === ORIENTATION_ROTATE_180 ) return 180;
|
||||
if ( orientation === ORIENTATION_ROTATE_270 ) return 270;
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const StandardCamera = ( ): Node => {
|
||||
const {
|
||||
addCameraPhotosToCurrentObservation,
|
||||
createObsWithCameraPhotos, cameraPreviewUris, setCameraPreviewUris, allObsPhotoUris,
|
||||
createObsWithCameraPhotos,
|
||||
cameraPreviewUris,
|
||||
setCameraPreviewUris,
|
||||
allObsPhotoUris,
|
||||
evidenceToAdd,
|
||||
setEvidenceToAdd
|
||||
} = useContext( ObsEditContext );
|
||||
@@ -54,7 +75,10 @@ const StandardCamera = ( ): Node => {
|
||||
return;
|
||||
}
|
||||
const cameraPhoto = await camera.current.takePhoto( takePhotoOptions );
|
||||
const newPhoto = await Photo.new( cameraPhoto.path );
|
||||
const newPhoto = await Photo.new( cameraPhoto.path, {
|
||||
rotation:
|
||||
orientationToRotation( cameraPhoto.metadata.Orientation )
|
||||
} );
|
||||
const uri = newPhoto.localFilePath;
|
||||
|
||||
setCameraPreviewUris( cameraPreviewUris.concat( [uri] ) );
|
||||
@@ -89,13 +113,35 @@ const StandardCamera = ( ): Node => {
|
||||
navigation.navigate( "ObsEdit" );
|
||||
};
|
||||
|
||||
const renderCameraOptionsButtons = icon => (
|
||||
<Avatar.Icon
|
||||
size={40}
|
||||
icon={icon}
|
||||
style={{ backgroundColor: colors.gray }}
|
||||
/>
|
||||
);
|
||||
const renderAddObsButtons = icon => {
|
||||
let testID = "";
|
||||
let accessibilityLabel = "";
|
||||
switch ( icon ) {
|
||||
case "flash":
|
||||
testID = "flash-button-label-flash";
|
||||
accessibilityLabel = t( "flash-button-label-flash" );
|
||||
break;
|
||||
case "flash-off":
|
||||
testID = "flash-button-label-flash-off";
|
||||
accessibilityLabel = t( "flash-button-label-flash-off" );
|
||||
break;
|
||||
case "camera-flip":
|
||||
testID = "camera-button-label-switch-camera";
|
||||
accessibilityLabel = t( "camera-button-label-switch-camera" );
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<Avatar.Icon
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
size={40}
|
||||
icon={icon}
|
||||
style={{ backgroundColor: colors.gray }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCameraButton = ( icon, disabled ) => (
|
||||
<Avatar.Icon
|
||||
@@ -119,11 +165,13 @@ const StandardCamera = ( ): Node => {
|
||||
<View className="flex-row justify-between w-screen mb-4 px-4">
|
||||
{hasFlash ? (
|
||||
<Pressable onPress={toggleFlash}>
|
||||
{renderCameraOptionsButtons( "flash" )}
|
||||
{takePhotoOptions.flash === "on"
|
||||
? renderAddObsButtons( "flash" )
|
||||
: renderAddObsButtons( "flash-off" )}
|
||||
</Pressable>
|
||||
) : <View />}
|
||||
<Pressable onPress={flipCamera}>
|
||||
{renderCameraOptionsButtons( "camera-flip" )}
|
||||
{renderAddObsButtons( "camera-flip" )}
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="bg-black w-screen h-32 flex-row justify-between items-center px-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@react-navigation/drawer";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
type Props = {
|
||||
state: any,
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
const CustomDrawerContent = ( { ...props }: Props ): Node => {
|
||||
// $FlowFixMe
|
||||
const { state, navigation, descriptors } = props;
|
||||
const currentUser = useCurrentUser( );
|
||||
|
||||
return (
|
||||
<DrawerContentScrollView state={state} navigation={navigation} descriptors={descriptors}>
|
||||
@@ -31,10 +33,6 @@ const CustomDrawerContent = ( { ...props }: Props ): Node => {
|
||||
label="projects"
|
||||
onPress={( ) => navigation.navigate( "projects" )}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="help"
|
||||
onPress={( ) => console.log( "nav to help/tutorials" )}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="about"
|
||||
onPress={( ) => navigation.navigate( "about" )}
|
||||
@@ -48,7 +46,7 @@ const CustomDrawerContent = ( { ...props }: Props ): Node => {
|
||||
onPress={( ) => navigation.navigate( "network" )}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="login"
|
||||
label={currentUser ? "logout" : "login"}
|
||||
onPress={( ) => navigation.navigate( "login" )}
|
||||
/>
|
||||
</DrawerContentScrollView>
|
||||
|
||||
@@ -18,20 +18,18 @@ import {
|
||||
|
||||
type Props = {
|
||||
item: Object,
|
||||
handlePress: Function,
|
||||
reviewedIds: Array<number>,
|
||||
setReviewedIds: Function
|
||||
}
|
||||
|
||||
const GridItem = ( {
|
||||
item, handlePress, reviewedIds, setReviewedIds
|
||||
item, reviewedIds, setReviewedIds
|
||||
}: Props ): Node => {
|
||||
const [showLoadingWheel, setShowLoadingWheel] = useState( false );
|
||||
const commonName = item.taxon && item.taxon.preferred_common_name;
|
||||
const name = item.taxon ? item.taxon.name : "unknown";
|
||||
const isSpecies = item.taxon && item.taxon.rank === "species";
|
||||
const wasReviewed = reviewedIds.includes( item.id );
|
||||
const onPress = ( ) => handlePress( item );
|
||||
// TODO: fix whatever funkiness is preventing realm mapTo from correctly
|
||||
// displaying camelcased item keys on ObservationList
|
||||
|
||||
@@ -62,14 +60,13 @@ const GridItem = ( {
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[
|
||||
viewStyles.gridItem,
|
||||
wasReviewed && viewStyles.markReviewed
|
||||
]}
|
||||
testID={`ObsList.gridItem.${item.uuid}`}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Navigate to observation details screen"
|
||||
accessibilityLabel={t( "Navigate-to-observation-details" )}
|
||||
>
|
||||
<Image
|
||||
source={imageUri}
|
||||
|
||||
@@ -23,7 +23,6 @@ const GridView = ( {
|
||||
const renderGridItem = ( { item } ) => (
|
||||
<GridItem
|
||||
item={item}
|
||||
handlePress={( ) => console.log( "press in identify" )}
|
||||
reviewedIds={reviewedIds}
|
||||
setReviewedIds={setReviewedIds}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
findNodeHandle,
|
||||
Linking,
|
||||
TouchableOpacity
|
||||
} from "react-native";
|
||||
@@ -34,6 +33,7 @@ const Login = ( ): Node => {
|
||||
const [loggedIn, setLoggedIn] = useState( false );
|
||||
const [error, setError] = useState( null );
|
||||
const [loading, setLoading] = useState( false );
|
||||
const [extraScrollHeight, setExtraScrollHeight] = useState( 0 );
|
||||
|
||||
useEffect( ( ) => {
|
||||
let isCurrent = true;
|
||||
@@ -76,10 +76,6 @@ const Login = ( ): Node => {
|
||||
Linking.openURL( "https://www.inaturalist.org/users/password/new" );
|
||||
};
|
||||
|
||||
const scrollToInput = node => {
|
||||
keyboardScrollRef?.current?.scrollToFocusedInput( node );
|
||||
};
|
||||
|
||||
const loginForm = (
|
||||
<>
|
||||
<Image
|
||||
@@ -103,7 +99,7 @@ const Login = ( ): Node => {
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
selectionColor={colors.black}
|
||||
onFocus={e => scrollToInput( findNodeHandle( e.target ) )}
|
||||
onFocus={() => setExtraScrollHeight( 200 )}
|
||||
/>
|
||||
<Text className="text-base mb-1 mt-5">{t( "Password" )}</Text>
|
||||
<TextInput
|
||||
@@ -116,10 +112,10 @@ const Login = ( ): Node => {
|
||||
secureTextEntry
|
||||
testID="Login.password"
|
||||
selectionColor={colors.black}
|
||||
onFocus={e => scrollToInput( findNodeHandle( e.target ) )}
|
||||
onFocus={() => setExtraScrollHeight( 200 )}
|
||||
/>
|
||||
<TouchableOpacity onPress={forgotPassword}>
|
||||
<Text className="underline mt-4 self-end">{t( "Forgot-Password" )}</Text>
|
||||
<Text className="underline mt-2 self-end">{t( "Forgot-Password" )}</Text>
|
||||
</TouchableOpacity>
|
||||
{error && <Text className="text-red self-center mt-5">{error}</Text>}
|
||||
<Button
|
||||
@@ -147,9 +143,11 @@ const Login = ( ): Node => {
|
||||
<SafeAreaView className="flex-1">
|
||||
{loggedIn ? <Logout /> : (
|
||||
<KeyboardAwareScrollView
|
||||
keyboardShouldPersistTaps="always"
|
||||
ref={keyboardScrollRef}
|
||||
enableOnAndroid
|
||||
extraHeight={290}
|
||||
enableAutomaticScroll
|
||||
extraScrollHeight={extraScrollHeight}
|
||||
className="p-8"
|
||||
>
|
||||
{renderBackButton( )}
|
||||
|
||||
@@ -13,14 +13,12 @@ const SignUp = (): Node => {
|
||||
const [username, setUsername] = useState( "" );
|
||||
const [password, setPassword] = useState( "" );
|
||||
|
||||
const register = async () => {
|
||||
const error = await registerUser(
|
||||
const register = async ( ) => {
|
||||
await registerUser(
|
||||
email,
|
||||
username,
|
||||
password
|
||||
);
|
||||
|
||||
console.log( "Register", error );
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,29 +2,41 @@
|
||||
|
||||
import searchMessages from "api/messages";
|
||||
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
|
||||
import { Text } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
import MessageList from "./MessageList";
|
||||
|
||||
const Messages = ( ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const {
|
||||
data,
|
||||
isLoading
|
||||
} = useAuthenticatedQuery(
|
||||
["searchMessages"],
|
||||
optsWithAuth => searchMessages( { page: 1 }, optsWithAuth )
|
||||
optsWithAuth => searchMessages( { page: 1 }, optsWithAuth ),
|
||||
{
|
||||
enabled: !!currentUser
|
||||
}
|
||||
);
|
||||
// TODO: Reload when accessing again
|
||||
|
||||
return (
|
||||
<ViewWithFooter>
|
||||
<MessageList
|
||||
loading={isLoading}
|
||||
messageList={data}
|
||||
testID="Messages.messages"
|
||||
/>
|
||||
{currentUser ? (
|
||||
<MessageList
|
||||
loading={isLoading}
|
||||
messageList={data}
|
||||
testID="Messages.messages"
|
||||
/>
|
||||
) : (
|
||||
<Text className="self-center">
|
||||
{t( "You-must-be-logged-in-to-view-messages" )}
|
||||
</Text>
|
||||
)}
|
||||
</ViewWithFooter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import NetworkLogger from "react-native-network-logger";
|
||||
@@ -10,6 +11,7 @@ import ViewWithFooter from "./SharedComponents/ViewWithFooter";
|
||||
const NetworkLogging = ( ): Node => (
|
||||
<ViewWithFooter>
|
||||
<NetworkLogger />
|
||||
<View className="pb-40" />
|
||||
</ViewWithFooter>
|
||||
);
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const ActivityItem = ( {
|
||||
className="flex-row my-3 ml-3 items-center"
|
||||
onPress={navToTaxonDetails}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="go to taxon details"
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
>
|
||||
<SmallSquareImage uri={Taxon.uri( taxon )} />
|
||||
<View>
|
||||
|
||||
@@ -1,31 +1,117 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import createIdentification from "api/identifications";
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { formatISO } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
import ActivityItem from "./ActivityItem";
|
||||
|
||||
type Props = {
|
||||
ids: Array<Object>,
|
||||
comments: Array<Object>,
|
||||
uuid:string,
|
||||
observation:Object,
|
||||
comments:Array<Object>,
|
||||
navToTaxonDetails: Function,
|
||||
navToUserProfile: number => { },
|
||||
toggleRefetch: Function,
|
||||
refetchRemoteObservation: Function
|
||||
refetchRemoteObservation: Function,
|
||||
openCommentBox: Function,
|
||||
showCommentBox:boolean
|
||||
}
|
||||
|
||||
const ActivityTab = ( {
|
||||
comments, ids, navToTaxonDetails, navToUserProfile, toggleRefetch, refetchRemoteObservation
|
||||
uuid, observation, comments, navToTaxonDetails, navToUserProfile,
|
||||
toggleRefetch, refetchRemoteObservation, openCommentBox, showCommentBox
|
||||
}: Props ): React.Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const userId = currentUser?.id;
|
||||
const [ids, setIds] = useState<Array<Object>>( [] );
|
||||
const navigation = useNavigation( );
|
||||
|
||||
const showErrorAlert = error => Alert.alert(
|
||||
"Error",
|
||||
error,
|
||||
[{ text: t( "OK" ) }],
|
||||
{
|
||||
cancelable: true
|
||||
}
|
||||
);
|
||||
|
||||
const createIdentificationMutation = useAuthenticatedMutation(
|
||||
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => setIds( [...ids, data[0]] ),
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-identification", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-identification", { error: t( "Unknown-error" ) } );
|
||||
}
|
||||
|
||||
// Remove temporary ID and show error
|
||||
setIds( [...ids] );
|
||||
showErrorAlert( error );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
// set initial ids for activity tab
|
||||
const currentIds = observation?.identifications;
|
||||
if ( currentIds
|
||||
&& ids.length === 0
|
||||
&& currentIds.length !== ids.length ) {
|
||||
setIds( currentIds );
|
||||
}
|
||||
}, [observation, ids] );
|
||||
|
||||
if ( comments.length === 0 && ids.length === 0 ) {
|
||||
return <Text>{t( "No-comments-or-ids-to-display" )}</Text>;
|
||||
}
|
||||
|
||||
const activityItems = ids.concat( [...comments] )
|
||||
.sort( ( a, b ) => ( b.created_at - a.created_at ) );
|
||||
const onIDAdded = async identification => {
|
||||
// Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it)
|
||||
const newId = {
|
||||
body: identification.body,
|
||||
taxon: identification.taxon,
|
||||
user: {
|
||||
id: userId,
|
||||
login: currentUser?.login,
|
||||
signedIn: true
|
||||
},
|
||||
created_at: formatISO( Date.now() ),
|
||||
uuid: identification.uuid,
|
||||
vision: false,
|
||||
// This tells us to render is ghosted (since it's temporarily visible
|
||||
// until getting a response from the server)
|
||||
temporary: true
|
||||
};
|
||||
setIds( [...ids, newId] );
|
||||
|
||||
return activityItems.map( item => {
|
||||
createIdentificationMutation.mutate( {
|
||||
identification: {
|
||||
observation_id: uuid,
|
||||
taxon_id: newId.taxon.id,
|
||||
body: newId.body
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const navToAddID = ( ) => {
|
||||
navigation.push( "AddID", { onIDAdded, goBackOnSave: true } );
|
||||
};
|
||||
|
||||
const activityItems = ids.concat( [...comments] )
|
||||
.sort( ( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) ) );
|
||||
|
||||
const activitytemsList = activityItems.map( item => {
|
||||
const handlePress = ( ) => navToUserProfile( item?.user?.id );
|
||||
// this should all perform similarly to the activity tab on web
|
||||
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/components/activity_item.jsx
|
||||
@@ -40,6 +126,27 @@ const ActivityTab = ( {
|
||||
/>
|
||||
);
|
||||
} );
|
||||
|
||||
return (
|
||||
<View>
|
||||
{activitytemsList}
|
||||
<View className="flex-row my-10 justify-evenly">
|
||||
<Button
|
||||
text={t( "Suggest-an-ID" )}
|
||||
onPress={navToAddID}
|
||||
className="mx-3"
|
||||
testID="ObsDetail.cvSuggestionsButton"
|
||||
/>
|
||||
<Button
|
||||
text={t( "Add-Comment" )}
|
||||
onPress={openCommentBox}
|
||||
className="mx-3"
|
||||
testID="ObsDetail.commentButton"
|
||||
disabled={showCommentBox}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTab;
|
||||
|
||||
38
src/components/ObsDetails/Attribution.js
Normal file
38
src/components/ObsDetails/Attribution.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// @flow
|
||||
|
||||
import { Text } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
observation: Object
|
||||
}
|
||||
|
||||
// lifted from web:
|
||||
// https://github.com/inaturalist/inaturalist/blob/768b9263931ebeea229bbc47d8442ca6b0377d45/app/webpack/shared/components/observation_attribution.jsx
|
||||
const Attribution = ( { observation }: Props ): Node => {
|
||||
const { user } = observation;
|
||||
const licenseCode = observation.license_code;
|
||||
const copyrightAttribution = user ? ( user.name || user.login ) : t( "unknown" );
|
||||
|
||||
const renderLicenseCode = ( ) => {
|
||||
if ( !licenseCode ) {
|
||||
return t( "all-rights-reserved" );
|
||||
} if ( licenseCode === "cc0" ) {
|
||||
return t( "no-rights-reserved" ) + licenseCode;
|
||||
}
|
||||
return t( "some-rights-reserved" ) + licenseCode;
|
||||
};
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{t( "Observation-Attribution", {
|
||||
attribution: copyrightAttribution,
|
||||
licenseCode: renderLicenseCode( )
|
||||
} )}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default Attribution;
|
||||
@@ -1,15 +1,15 @@
|
||||
// @flow
|
||||
|
||||
import Map from "components/SharedComponents/Map";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import { textStyles, viewStyles } from "styles/obsDetails/obsDetails";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
import Attribution from "./Attribution";
|
||||
import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase";
|
||||
|
||||
type Props = {
|
||||
@@ -18,64 +18,57 @@ type Props = {
|
||||
|
||||
const DataTab = ( { observation }: Props ): Node => {
|
||||
const application = observation?.application?.name;
|
||||
const attribution = observation?.taxon?.default_photo?.attribution;
|
||||
|
||||
const displayTimeObserved = ( ) => {
|
||||
const timeObservedAt = checkCamelAndSnakeCase( observation, "timeObservedAt" );
|
||||
if ( timeObservedAt ) {
|
||||
return format( parseISO( timeObservedAt ), "M/d/yy HH:mm a" );
|
||||
}
|
||||
if ( observation.observed_on_string ) {
|
||||
return observation.observed_on_string;
|
||||
}
|
||||
return "";
|
||||
const displayTime = datetime => {
|
||||
const time = checkCamelAndSnakeCase( observation, datetime );
|
||||
return time ? format( parseISO( time ), "M/d/yy HH:mm a" ) : "";
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={textStyles.dataTabHeader}>{t( "Notes" )}</Text>
|
||||
<Text style={textStyles.dataTabText}>{observation.description || "no description"}</Text>
|
||||
<Text style={textStyles.dataTabHeader}>{t( "Location" )}</Text>
|
||||
<View className="px-5">
|
||||
{observation.description && (
|
||||
<>
|
||||
<Text className="text-lg my-3">{t( "Notes" )}</Text>
|
||||
<Text>{observation.description}</Text>
|
||||
</>
|
||||
)}
|
||||
<Text className="text-lg my-3">{t( "Location" )}</Text>
|
||||
</View>
|
||||
<Map
|
||||
obsLatitude={observation.latitude}
|
||||
obsLongitude={observation.longitude}
|
||||
mapHeight={150}
|
||||
/>
|
||||
<View style={[viewStyles.rowWithIcon, viewStyles.locationContainer]}>
|
||||
<IconMaterial name="location-pin" size={15} color={colors.logInGray} />
|
||||
<Text style={textStyles.dataTabText}>
|
||||
{checkCamelAndSnakeCase( observation, "placeGuess" )}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[textStyles.dataTabHeader, textStyles.dataTabDateHeader]}>{t( "Date" )}</Text>
|
||||
<View style={[viewStyles.rowWithIcon, viewStyles.dataTabSub]}>
|
||||
<IconMaterial name="schedule" size={15} color={colors.logInGray} />
|
||||
<Text
|
||||
style={textStyles.dataTabText}
|
||||
>
|
||||
{`${t( "Date-observed-colon" )} ${displayTimeObserved( )}`}
|
||||
</Text>
|
||||
</View>
|
||||
{ observation._synced_at && (
|
||||
<View style={[viewStyles.rowWithIcon, viewStyles.dataTabView, viewStyles.dataTabSub]}>
|
||||
<IconMaterial name="schedule" size={15} color={colors.logInGray} />
|
||||
<Text
|
||||
style={textStyles.dataTabText}
|
||||
>
|
||||
{`${t( "Date-uploaded-colon" )} ${format( observation._synced_at, "M/d/yy HH:mm a" )}`}
|
||||
</Text>
|
||||
</View>
|
||||
) }
|
||||
<Text style={textStyles.dataTabHeader}>{t( "Projects" )}</Text>
|
||||
<Text style={textStyles.dataTabHeader}>{t( "Other-Data" )}</Text>
|
||||
{attribution && <Text style={textStyles.dataTabText}>{attribution}</Text>}
|
||||
{application && (
|
||||
<View className="px-5">
|
||||
<View className="flex-row my-3">
|
||||
<IconMaterial name="location-pin" size={15} color={colors.logInGray} />
|
||||
<Text className="ml-2">
|
||||
{checkCamelAndSnakeCase( observation, "placeGuess" )}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-lg my-3">{t( "Date" )}</Text>
|
||||
<View className="flex-row">
|
||||
<IconMaterial name="schedule" size={15} color={colors.logInGray} />
|
||||
<Text className="ml-2">
|
||||
{`${t( "Date-observed-colon" )} ${displayTime( "timeObservedAt" )}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row">
|
||||
<IconMaterial name="schedule" size={15} color={colors.logInGray} />
|
||||
<Text className="ml-2">
|
||||
{`${t( "Date-uploaded-colon" )} ${displayTime( "createdAt" )}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-lg my-3">{t( "Other-Data" )}</Text>
|
||||
<Attribution observation={observation} />
|
||||
{application && (
|
||||
<>
|
||||
<Text style={textStyles.dataTabText}>{t( "This-observation-was-created-using" )}</Text>
|
||||
<Text style={textStyles.dataTabText}>{application}</Text>
|
||||
<Text className="mt-5">{t( "This-observation-was-created-using" )}</Text>
|
||||
<Text>{application}</Text>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createComment } from "api/comments";
|
||||
import createIdentification from "api/identifications";
|
||||
import {
|
||||
faveObservation, fetchRemoteObservation, markObservationUpdatesViewed, unfaveObservation
|
||||
} from "api/observations";
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
import PhotoScroll from "components/SharedComponents/PhotoScroll";
|
||||
import QualityBadge from "components/SharedComponents/QualityBadge";
|
||||
import ScrollWithFooter from "components/SharedComponents/ScrollWithFooter";
|
||||
@@ -21,20 +19,24 @@ import _ from "lodash";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useEffect, useState
|
||||
useEffect,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
Alert, LogBox
|
||||
Alert,
|
||||
LogBox
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, Button as IconButton } from "react-native-paper";
|
||||
import createUUID from "react-native-uuid";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import Observation from "realmModels/Observation";
|
||||
import Taxon from "realmModels/Taxon";
|
||||
import User from "realmModels/User";
|
||||
import { formatObsListTime } from "sharedHelpers/dateAndTime";
|
||||
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useLocalObservation from "sharedHooks/useLocalObservation";
|
||||
import { imageStyles } from "styles/obsDetails/obsDetails";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
@@ -56,28 +58,32 @@ const ObsDetails = ( ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const userId = currentUser?.id;
|
||||
const [refetch, setRefetch] = useState( false );
|
||||
const [showCommentBox, setShowCommentBox] = useState( false );
|
||||
const { params } = useRoute( );
|
||||
const { uuid } = params;
|
||||
const [tab, setTab] = useState( 0 );
|
||||
const navigation = useNavigation( );
|
||||
const [ids, setIds] = useState( [] );
|
||||
const [addingComment, setAddingComment] = useState( false );
|
||||
const realm = useRealm( );
|
||||
const localObservation = realm?.objectForPrimaryKey( "Observation", uuid );
|
||||
const localObservation = useLocalObservation( uuid );
|
||||
const [showCommentBox, setShowCommentBox] = useState( false );
|
||||
const [addingComment, setAddingComment] = useState( false );
|
||||
const [comments, setComments] = useState( [] );
|
||||
|
||||
const queryClient = useQueryClient( );
|
||||
|
||||
const remoteObservationParams = {
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
|
||||
const {
|
||||
data: remoteObservation,
|
||||
refetch: refetchRemoteObservation
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchRemoteObservation", uuid],
|
||||
optsWithAuth => fetchRemoteObservation( uuid, { }, optsWithAuth )
|
||||
optsWithAuth => fetchRemoteObservation( uuid, remoteObservationParams, optsWithAuth )
|
||||
);
|
||||
|
||||
const observation = localObservation || remoteObservation;
|
||||
// const observation = remoteObservation;
|
||||
|
||||
const mutationOptions = {
|
||||
onSuccess: ( ) => {
|
||||
@@ -86,6 +92,19 @@ const ObsDetails = ( ): Node => {
|
||||
}
|
||||
};
|
||||
|
||||
const markViewedMutation = useAuthenticatedMutation(
|
||||
( viewedParams, optsWithAuth ) => markObservationUpdatesViewed( viewedParams, optsWithAuth ),
|
||||
mutationOptions
|
||||
);
|
||||
|
||||
const taxon = observation?.taxon;
|
||||
const user = observation?.user;
|
||||
const faves = observation?.faves;
|
||||
const observationPhotos = observation?.observationPhotos || observation?.observation_photos;
|
||||
const currentUserFaved = faves?.length > 0 ? faves.find( fave => fave.user.id === userId ) : null;
|
||||
|
||||
const showActivityTab = ( ) => setTab( 0 );
|
||||
const showDataTab = ( ) => setTab( 1 );
|
||||
const showErrorAlert = error => Alert.alert(
|
||||
"Error",
|
||||
error,
|
||||
@@ -95,6 +114,8 @@ const ObsDetails = ( ): Node => {
|
||||
}
|
||||
);
|
||||
|
||||
const toggleRefetch = ( ) => setRefetch( !refetch );
|
||||
const openCommentBox = ( ) => setShowCommentBox( true );
|
||||
const createCommentMutation = useAuthenticatedMutation(
|
||||
( commentParams, optsWithAuth ) => createComment( commentParams, optsWithAuth ),
|
||||
{
|
||||
@@ -114,120 +135,6 @@ const ObsDetails = ( ): Node => {
|
||||
onSettled: ( ) => setAddingComment( false )
|
||||
}
|
||||
);
|
||||
|
||||
const createIdentificationMutation = useAuthenticatedMutation(
|
||||
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => setIds( [...ids, data[0]] ),
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-identification", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-identification", { error: t( "Unknown-error" ) } );
|
||||
}
|
||||
|
||||
// Remove temporary ID and show error
|
||||
setIds( [...ids] );
|
||||
showErrorAlert( error );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const markViewedMutation = useAuthenticatedMutation(
|
||||
( viewedParams, optsWithAuth ) => markObservationUpdatesViewed( viewedParams, optsWithAuth ),
|
||||
mutationOptions
|
||||
);
|
||||
|
||||
const taxon = observation?.taxon;
|
||||
const user = observation?.user;
|
||||
const faves = observation?.faves;
|
||||
const currentUserFaved = faves?.length > 0 ? faves.find( fave => fave.user.id === userId ) : null;
|
||||
|
||||
useEffect( ( ) => {
|
||||
// set initial ids for activity tab
|
||||
const currentIds = observation?.identifications;
|
||||
if ( currentIds
|
||||
&& ids.length === 0
|
||||
&& currentIds.length !== ids.length ) {
|
||||
setIds( currentIds );
|
||||
}
|
||||
}, [observation, ids] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
// set initial comments for activity tab
|
||||
const currentComments = observation?.comments;
|
||||
if ( currentComments
|
||||
&& comments.length === 0
|
||||
&& currentComments.length !== comments.length ) {
|
||||
setComments( currentComments );
|
||||
}
|
||||
}, [observation, comments] );
|
||||
|
||||
const showActivityTab = ( ) => setTab( 0 );
|
||||
const showDataTab = ( ) => setTab( 1 );
|
||||
|
||||
const toggleRefetch = ( ) => setRefetch( !refetch );
|
||||
|
||||
useEffect( () => {
|
||||
const markViewedLocally = async ( ) => {
|
||||
if ( !localObservation ) { return; }
|
||||
realm?.write( ( ) => {
|
||||
localObservation.viewed = true;
|
||||
} );
|
||||
};
|
||||
|
||||
if ( !observation?.viewed ) {
|
||||
markViewedMutation.mutate( { id: uuid } );
|
||||
markViewedLocally( );
|
||||
}
|
||||
}, [observation, localObservation, realm, markViewedMutation, uuid] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const obsCreatedLocally = observation?.id === null;
|
||||
const obsOwnedByCurrentUser = observation?.user?.id === currentUser?.id;
|
||||
|
||||
const navToObsEdit = ( ) => navigation.navigate( "ObsEdit", { uuid: observation?.uuid } );
|
||||
const editIcon = ( ) => ( obsCreatedLocally || obsOwnedByCurrentUser )
|
||||
&& <IconButton icon="pencil" onPress={navToObsEdit} textColor={colors.gray} />;
|
||||
|
||||
navigation.setOptions( {
|
||||
headerRight: editIcon
|
||||
} );
|
||||
}, [navigation, observation, currentUser] );
|
||||
|
||||
if ( !observation ) { return null; }
|
||||
|
||||
const photos = _.compact( Array.from( observation.observationPhotos ).map( op => op.photo ) );
|
||||
|
||||
const onIDAdded = async identification => {
|
||||
// Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it)
|
||||
const newId = {
|
||||
body: identification.body,
|
||||
taxon: identification.taxon,
|
||||
user: {
|
||||
id: userId,
|
||||
login: currentUser?.login,
|
||||
signedIn: true
|
||||
},
|
||||
created_at: formatISO( Date.now() ),
|
||||
uuid: identification.uuid,
|
||||
vision: false,
|
||||
// This tells us to render is ghosted (since it's temporarily visible
|
||||
// until getting a response from the server)
|
||||
temporary: true
|
||||
};
|
||||
setIds( [...ids, newId] );
|
||||
|
||||
createIdentificationMutation.mutate( {
|
||||
identification: {
|
||||
observation_id: uuid,
|
||||
taxon_id: newId.taxon.id,
|
||||
body: newId.body
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const onCommentAdded = async commentBody => {
|
||||
// Add temporary comment to observation.comments ("ghosted" comment,
|
||||
// while we're trying to add it)
|
||||
@@ -255,12 +162,48 @@ const ObsDetails = ( ): Node => {
|
||||
} );
|
||||
};
|
||||
|
||||
useEffect( ( ) => {
|
||||
const markViewedLocally = async ( ) => {
|
||||
realm?.write( ( ) => {
|
||||
localObservation.viewed = true;
|
||||
} );
|
||||
};
|
||||
|
||||
if ( localObservation && !localObservation?.viewed ) {
|
||||
markViewedMutation.mutate( { id: uuid } );
|
||||
markViewedLocally( );
|
||||
}
|
||||
}, [observation, localObservation, realm, markViewedMutation, uuid] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const obsCreatedLocally = observation?.id === null;
|
||||
const obsOwnedByCurrentUser = observation?.user?.id === currentUser?.id;
|
||||
|
||||
const navToObsEdit = ( ) => navigation.navigate( "ObsEdit", { uuid: observation?.uuid } );
|
||||
const editIcon = ( ) => ( obsCreatedLocally || obsOwnedByCurrentUser )
|
||||
&& <IconButton icon="pencil" onPress={navToObsEdit} textColor={colors.gray} />;
|
||||
|
||||
navigation.setOptions( {
|
||||
headerRight: editIcon
|
||||
} );
|
||||
}, [navigation, observation, currentUser] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
// set initial comments for activity tab
|
||||
const currentComments = observation?.comments;
|
||||
if ( currentComments
|
||||
&& comments.length === 0
|
||||
&& currentComments.length !== comments.length ) {
|
||||
setComments( currentComments );
|
||||
}
|
||||
}, [observation, comments] );
|
||||
|
||||
if ( !observation ) { return null; }
|
||||
|
||||
const photos = _.compact( Array.from( observationPhotos ).map( op => op.photo ) );
|
||||
|
||||
const navToUserProfile = id => navigation.navigate( "UserProfile", { userId: id } );
|
||||
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } );
|
||||
const navToAddID = ( ) => {
|
||||
navigation.push( "AddID", { onIDAdded, goBackOnSave: true } );
|
||||
};
|
||||
const openCommentBox = ( ) => setShowCommentBox( true );
|
||||
|
||||
const showTaxon = ( ) => {
|
||||
if ( !taxon ) { return <Text>{t( "Unknown-organism" )}</Text>; }
|
||||
@@ -272,7 +215,7 @@ const ObsDetails = ( ): Node => {
|
||||
onPress={navToTaxonDetails}
|
||||
testID={`ObsDetails.taxon.${taxon.id}`}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="go to taxon details"
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
>
|
||||
<Text>
|
||||
{checkCamelAndSnakeCase( taxon, "preferredCommonName" )}
|
||||
@@ -296,9 +239,8 @@ const ObsDetails = ( ): Node => {
|
||||
}
|
||||
};
|
||||
|
||||
const displayCreatedAt = ( ) => ( observation.createdAt
|
||||
? observation.createdAt
|
||||
: formatObsListTime( observation._created_at ) );
|
||||
const displayCreatedAt = ( ) => ( observation?.created_at
|
||||
? formatObsListTime( observation.created_at ) : "" );
|
||||
|
||||
const displayTab = ( handlePress, testID, tabText, active ) => {
|
||||
let textClassName = "color-gray text-xl font-bold";
|
||||
@@ -321,7 +263,6 @@ const ObsDetails = ( ): Node => {
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const displayPhoto = () => {
|
||||
if ( photos.length > 0 || observation.observationSounds.length > 0 ) {
|
||||
return (
|
||||
@@ -363,7 +304,6 @@ const ObsDetails = ( ): Node => {
|
||||
{showTaxon( )}
|
||||
<View>
|
||||
<View className="flex-row my-1">
|
||||
{/* TODO: figure out how to change icon tint color with Tailwind */}
|
||||
<Image
|
||||
style={imageStyles.smallIcon}
|
||||
source={require( "images/ic_id.png" )}
|
||||
@@ -390,37 +330,27 @@ const ObsDetails = ( ): Node => {
|
||||
{tab === 0
|
||||
? (
|
||||
<ActivityTab
|
||||
ids={ids}
|
||||
uuid={uuid}
|
||||
observation={observation}
|
||||
comments={comments}
|
||||
navToTaxonDetails={navToTaxonDetails}
|
||||
navToUserProfile={navToUserProfile}
|
||||
toggleRefetch={toggleRefetch}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
openCommentBox={openCommentBox}
|
||||
showCommentBox={showCommentBox}
|
||||
/>
|
||||
)
|
||||
: <DataTab observation={observation} />}
|
||||
{addingComment && (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-row my-10 justify-evenly">
|
||||
<Button
|
||||
text={t( "Suggest-an-ID" )}
|
||||
onPress={navToAddID}
|
||||
className="mx-3"
|
||||
testID="ObsDetail.cvSuggestionsButton"
|
||||
/>
|
||||
<Button
|
||||
text={t( "Add-Comment" )}
|
||||
onPress={openCommentBox}
|
||||
className="mx-3"
|
||||
testID="ObsDetail.commentButton"
|
||||
disabled={showCommentBox}
|
||||
/>
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)}
|
||||
</ScrollWithFooter>
|
||||
<AddCommentModal
|
||||
// potential to move this modal to ActivityTab and have it handle comments
|
||||
// and ids but there were issues with presenting the modal in a scrollview.
|
||||
onCommentAdded={onCommentAdded}
|
||||
showCommentBox={showCommentBox}
|
||||
setShowCommentBox={setShowCommentBox}
|
||||
|
||||
@@ -74,7 +74,6 @@ const AddEvidenceModal = ( {
|
||||
|
||||
const onRecordSound = () => {
|
||||
// TODO - need to implement
|
||||
console.log( "Record sound" );
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// @flow
|
||||
|
||||
import DateTimePicker from "components/SharedComponents/DateTimePicker";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useState } from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { displayDateTimeObsEdit } from "sharedHelpers/dateAndTime";
|
||||
import { createObservedOnStringForUpload, displayDateTimeObsEdit } from "sharedHelpers/dateAndTime";
|
||||
|
||||
type Props = {
|
||||
handleDatePicked: ( Date ) => void,
|
||||
currentObservation: Object
|
||||
}
|
||||
|
||||
const DatePicker = ( { handleDatePicked, currentObservation }: Props ): Node => {
|
||||
const DatePicker = ( { currentObservation }: Props ): Node => {
|
||||
const { updateObservationKey } = useContext( ObsEditContext );
|
||||
const { t } = useTranslation( );
|
||||
const [showModal, setShowModal] = useState( false );
|
||||
|
||||
@@ -20,19 +21,12 @@ const DatePicker = ( { handleDatePicked, currentObservation }: Props ): Node =>
|
||||
const closeModal = () => setShowModal( false );
|
||||
|
||||
const handlePicked = value => {
|
||||
handleDatePicked( value );
|
||||
const dateString = createObservedOnStringForUpload( value );
|
||||
updateObservationKey( "observed_on_string", dateString );
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const displayDate = ( ) => {
|
||||
if ( currentObservation.observed_on_string ) {
|
||||
return displayDateTimeObsEdit( currentObservation.observed_on_string );
|
||||
} if ( currentObservation.time_observed_at ) {
|
||||
// this is for observations already uploaded to iNat
|
||||
return displayDateTimeObsEdit( currentObservation.time_observed_at );
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const displayDate = ( ) => displayDateTimeObsEdit( currentObservation?.observed_on_string ) || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,13 +27,17 @@ const DeleteObservationDialog = ( {
|
||||
const navigation = useNavigation( );
|
||||
const { uuid } = currentObservation;
|
||||
|
||||
const handleLocalDeletion = ( ) => {
|
||||
deleteLocalObservation( uuid );
|
||||
hideDialog( );
|
||||
navigation.navigate( "ObsList" );
|
||||
};
|
||||
|
||||
const deleteObservationMutation = useAuthenticatedMutation(
|
||||
( params, optsWithAuth ) => deleteObservation( params, optsWithAuth ),
|
||||
{
|
||||
onSuccess: ( ) => {
|
||||
deleteLocalObservation( uuid );
|
||||
hideDialog( );
|
||||
navigation.navigate( "ObsList" );
|
||||
handleLocalDeletion( );
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -47,7 +51,13 @@ const DeleteObservationDialog = ( {
|
||||
<Dialog.Actions>
|
||||
<Button onPress={hideDialog} text={t( "Cancel" )} level="primary" className="m-0.5" />
|
||||
<Button
|
||||
onPress={( ) => deleteObservationMutation.mutate( { uuid } )}
|
||||
onPress={( ) => {
|
||||
if ( !currentObservation._synced_at ) {
|
||||
handleLocalDeletion( );
|
||||
} else {
|
||||
deleteObservationMutation.mutate( { uuid } );
|
||||
}
|
||||
}}
|
||||
text={t( "Yes-delete-observation" )}
|
||||
level="primary"
|
||||
className="m-0.5"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// @flow
|
||||
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import PhotoCarousel from "components/SharedComponents/PhotoCarousel";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useContext, useEffect, useRef, useState
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
|
||||
import fetchUserLocation from "sharedHelpers/fetchUserLocation";
|
||||
import { parseExifDateTime, usePhotoExif } from "sharedHooks/usePhotoExif";
|
||||
|
||||
import DatePicker from "./DatePicker";
|
||||
|
||||
@@ -31,44 +28,25 @@ const EvidenceSection = ( {
|
||||
}: Props ): Node => {
|
||||
const {
|
||||
currentObservation,
|
||||
updateObservationKey,
|
||||
updateObservationKeys
|
||||
} = useContext( ObsEditContext );
|
||||
const { params } = useRoute( );
|
||||
const lastScreen = params?.lastScreen;
|
||||
const mountedRef = useRef( true );
|
||||
// TODO move this logic to the model
|
||||
const isNewObservation = currentObservation && !currentObservation._created_at;
|
||||
const isNewObservationCameraPhoto = isNewObservation && lastScreen === "StandardCamera";
|
||||
const isNewObservationsWithoutPhotos = (
|
||||
isNewObservation
|
||||
&& lastScreen !== "StandardCamera"
|
||||
&& lastScreen !== "PhotoGallery"
|
||||
);
|
||||
const isNewObservationImportingPhotos = isNewObservation && lastScreen === "PhotoGallery";
|
||||
const [shouldFetchLocation, setShouldFetchLocation] = useState(
|
||||
currentObservation
|
||||
&& !currentObservation._synced_at
|
||||
&& (
|
||||
isNewObservationCameraPhoto
|
||||
|| isNewObservationsWithoutPhotos
|
||||
)
|
||||
);
|
||||
const [fetchingLocation, setFetchingLocation] = useState( false );
|
||||
const [positionalAccuracy, setPositionalAccuracy] = useState( INITIAL_POSITIONAL_ACCURACY );
|
||||
const [photoOriginalUris, setPhotoOriginalUris] = useState( [] );
|
||||
const [lastLocationFetchTime, setLastLocationFetchTime] = useState( 0 );
|
||||
const firstPhotoExif = usePhotoExif( photoOriginalUris.length > 0 ? photoOriginalUris[0] : null );
|
||||
const [exifDataImported, setExifDataImported] = useState( false );
|
||||
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const formatDecimal = coordinate => coordinate && coordinate.toFixed( 6 );
|
||||
|
||||
const updateObservedOn = value => updateObservationKey( "observed_on_string", value );
|
||||
|
||||
const latitude = currentObservation?.latitude;
|
||||
const longitude = currentObservation?.longitude;
|
||||
const hasLocation = latitude || longitude;
|
||||
|
||||
const [shouldFetchLocation, setShouldFetchLocation] = useState(
|
||||
currentObservation
|
||||
&& !currentObservation._created_at
|
||||
&& !currentObservation._synced_at
|
||||
&& !hasLocation
|
||||
);
|
||||
const [fetchingLocation, setFetchingLocation] = useState( false );
|
||||
const [positionalAccuracy, setPositionalAccuracy] = useState( INITIAL_POSITIONAL_ACCURACY );
|
||||
const [lastLocationFetchTime, setLastLocationFetchTime] = useState( 0 );
|
||||
|
||||
const formatDecimal = coordinate => coordinate && coordinate.toFixed( 6 );
|
||||
|
||||
// Hook version of componentWillUnmount. We use a ref to track mounted
|
||||
// state (not useState, which might get frozen in a closure for other
|
||||
@@ -88,17 +66,13 @@ const EvidenceSection = ( {
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !currentObservation ) return;
|
||||
|
||||
if ( !shouldFetchLocation ) return;
|
||||
|
||||
if ( fetchingLocation ) return;
|
||||
|
||||
const fetchLocation = async () => {
|
||||
// If the component is gone, you won't be able to updated it
|
||||
if ( !mountedRef.current ) return;
|
||||
|
||||
if ( !shouldFetchLocation ) return;
|
||||
if ( exifDataImported ) return;
|
||||
|
||||
setFetchingLocation( false );
|
||||
|
||||
@@ -127,8 +101,6 @@ const EvidenceSection = ( {
|
||||
&& positionalAccuracy >= TARGET_POSITIONAL_ACCURACY
|
||||
// Don't fetch location more than once a second
|
||||
&& Date.now() - lastLocationFetchTime >= 1000
|
||||
// Don't retrieve current location if EXIF data for the photo was imported
|
||||
&& !exifDataImported
|
||||
) {
|
||||
setFetchingLocation( true );
|
||||
setLastLocationFetchTime( Date.now() );
|
||||
@@ -145,64 +117,9 @@ const EvidenceSection = ( {
|
||||
shouldFetchLocation,
|
||||
updateObservationKeys,
|
||||
lastLocationFetchTime,
|
||||
setLastLocationFetchTime,
|
||||
exifDataImported
|
||||
setLastLocationFetchTime
|
||||
] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( !currentObservation ) return;
|
||||
|
||||
if ( isNewObservationImportingPhotos && currentObservation.observed_on_string
|
||||
&& !exifDataImported && photoUris.length > 0 ) {
|
||||
// In case of importing photos - clear out default observed_on
|
||||
updateObservationKeys( { observed_on_string: null } );
|
||||
}
|
||||
}, [currentObservation, exifDataImported,
|
||||
photoUris, updateObservationKeys, isNewObservationImportingPhotos] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( !currentObservation ) return;
|
||||
|
||||
if ( !currentObservation.id && firstPhotoExif && !exifDataImported ) {
|
||||
// New observation with imported photo - import EXIF data from it and
|
||||
// use it to set location/observed_on data
|
||||
setExifDataImported( true );
|
||||
|
||||
const newObsData = {};
|
||||
const observedOnDate = parseExifDateTime( firstPhotoExif.date );
|
||||
|
||||
if ( observedOnDate ) {
|
||||
newObsData.observed_on_string = createObservedOnStringForUpload( observedOnDate );
|
||||
}
|
||||
|
||||
if ( firstPhotoExif.latitude && firstPhotoExif.longitude ) {
|
||||
newObsData.latitude = firstPhotoExif.latitude;
|
||||
newObsData.longitude = firstPhotoExif.longitude;
|
||||
if ( firstPhotoExif.positional_accuracy ) {
|
||||
newObsData.positional_accuracy = firstPhotoExif.positional_accuracy;
|
||||
}
|
||||
}
|
||||
|
||||
if ( Object.keys( newObsData ).length > 0 ) {
|
||||
updateObservationKeys( newObsData );
|
||||
}
|
||||
}
|
||||
}, [currentObservation, firstPhotoExif, exifDataImported, updateObservationKeys] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !currentObservation || !currentObservation.observationPhotos ) { return; }
|
||||
setPhotoOriginalUris( Array.from( currentObservation.observationPhotos ).map(
|
||||
obsPhoto => obsPhoto.originalPhotoUri
|
||||
) );
|
||||
}, [currentObservation] );
|
||||
|
||||
const handleDatePicked = selectedDate => {
|
||||
if ( selectedDate ) {
|
||||
const dateString = createObservedOnStringForUpload( selectedDate );
|
||||
updateObservedOn( dateString );
|
||||
}
|
||||
};
|
||||
|
||||
const displayLocation = ( ) => {
|
||||
let location = "";
|
||||
if ( latitude ) {
|
||||
@@ -227,7 +144,7 @@ const EvidenceSection = ( {
|
||||
/>
|
||||
<Text>{currentObservation.place_guess}</Text>
|
||||
<Text>{displayLocation( ) || t( "No-Location" )}</Text>
|
||||
<DatePicker currentObservation={currentObservation} handleDatePicked={handleDatePicked} />
|
||||
<DatePicker currentObservation={currentObservation} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useContext } from "react";
|
||||
@@ -41,7 +42,7 @@ const IdentificationSection = ( ): Node => {
|
||||
<Button
|
||||
level="primary"
|
||||
onPress={navToAddID}
|
||||
text="Add-an-Identification"
|
||||
text={t( "Add-an-Identification" )}
|
||||
className="mx-10 my-3"
|
||||
testID="ObsEdit.Suggestions"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import { HeaderBackButton } from "@react-navigation/elements";
|
||||
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
|
||||
import MediaViewerModal from "components/MediaViewer/MediaViewerModal";
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
@@ -12,12 +13,13 @@ import React, {
|
||||
useCallback, useContext, useEffect, useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { BackHandler } from "react-native";
|
||||
import { ActivityIndicator, BackHandler } from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
|
||||
import { Menu } from "react-native-paper";
|
||||
import Photo from "realmModels/Photo";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useLocalObservation from "sharedHooks/useLocalObservation";
|
||||
import useLoggedIn from "sharedHooks/useLoggedIn";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
import AddEvidenceModal from "./AddEvidenceModal";
|
||||
import DeleteObservationDialog from "./DeleteObservationDialog";
|
||||
@@ -25,6 +27,7 @@ import EvidenceSection from "./EvidenceSection";
|
||||
import IdentificationSection from "./IdentificationSection";
|
||||
import ObsEditHeaderTitle from "./ObsEditHeaderTitle";
|
||||
import OtherDataSection from "./OtherDataSection";
|
||||
import SaveDialog from "./SaveDialog";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
@@ -37,7 +40,11 @@ const ObsEdit = ( ): Node => {
|
||||
saveObservation,
|
||||
saveAndUploadObservation,
|
||||
setObservations,
|
||||
resetObsEditContext
|
||||
resetObsEditContext,
|
||||
setNextScreen,
|
||||
loading,
|
||||
setLoading,
|
||||
unsavedChanges
|
||||
} = useContext( ObsEditContext );
|
||||
const obsPhotos = currentObservation?.observationPhotos;
|
||||
const photoUris = obsPhotos ? Array.from( obsPhotos ).map(
|
||||
@@ -46,11 +53,12 @@ const ObsEdit = ( ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const { params } = useRoute( );
|
||||
const localObservation = useLocalObservation( params?.uuid );
|
||||
const isLoggedIn = useLoggedIn( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const [mediaViewerVisible, setMediaViewerVisible] = useState( false );
|
||||
const [initialPhotoSelected, setInitialPhotoSelected] = useState( null );
|
||||
const [showAddEvidenceModal, setShowAddEvidenceModal] = useState( false );
|
||||
const [kebabMenuVisible, setKebabMenuVisible] = useState( false );
|
||||
const [showSaveDialog, setShowSaveDialog] = useState( false );
|
||||
|
||||
const scrollToInput = node => {
|
||||
// Add a 'scroll' ref to your ScrollView
|
||||
@@ -76,11 +84,28 @@ const ObsEdit = ( ): Node => {
|
||||
const showModal = ( ) => setMediaViewerVisible( true );
|
||||
const hideModal = ( ) => setMediaViewerVisible( false );
|
||||
|
||||
const handleBackButtonPress = useCallback( async ( ) => {
|
||||
const discardChanges = useCallback( ( ) => {
|
||||
setObservations( [] );
|
||||
navigation.goBack( );
|
||||
}, [navigation, setObservations] );
|
||||
|
||||
const handleBackButtonPress = useCallback( ( ) => {
|
||||
if ( unsavedChanges ) {
|
||||
setShowSaveDialog( true );
|
||||
} else {
|
||||
discardChanges( );
|
||||
}
|
||||
}, [unsavedChanges, discardChanges] );
|
||||
|
||||
const renderBackButton = useCallback( ( ) => (
|
||||
<HeaderBackButton
|
||||
tintColor={colors.black}
|
||||
onPress={handleBackButtonPress}
|
||||
// eslint-disable-next-line react-native/no-inline-styles
|
||||
style={{ left: -15 }}
|
||||
/>
|
||||
), [handleBackButtonPress] );
|
||||
|
||||
useFocusEffect(
|
||||
useCallback( ( ) => {
|
||||
// make sure an Android user cannot back out to MyObservations with the back arrow
|
||||
@@ -116,7 +141,8 @@ const ObsEdit = ( ): Node => {
|
||||
useEffect( ( ) => {
|
||||
const renderHeaderTitle = ( ) => <ObsEditHeaderTitle />;
|
||||
const headerOptions = {
|
||||
headerTitle: renderHeaderTitle
|
||||
headerTitle: renderHeaderTitle,
|
||||
headerLeft: renderBackButton
|
||||
};
|
||||
|
||||
// only show delete kebab menu for observations persisted to realm
|
||||
@@ -126,7 +152,7 @@ const ObsEdit = ( ): Node => {
|
||||
}
|
||||
|
||||
navigation.setOptions( headerOptions );
|
||||
}, [observations, navigation, renderKebabMenu, localObservation] );
|
||||
}, [observations, navigation, renderKebabMenu, localObservation, renderBackButton] );
|
||||
|
||||
const realm = useRealm( );
|
||||
|
||||
@@ -164,6 +190,10 @@ const ObsEdit = ( ): Node => {
|
||||
deleteDialogVisible={deleteDialogVisible}
|
||||
hideDialog={hideDialog}
|
||||
/>
|
||||
<SaveDialog
|
||||
showSaveDialog={showSaveDialog}
|
||||
discardChanges={discardChanges}
|
||||
/>
|
||||
<MediaViewerModal
|
||||
mediaViewerVisible={mediaViewerVisible}
|
||||
hideModal={hideModal}
|
||||
@@ -182,9 +212,15 @@ const ObsEdit = ( ): Node => {
|
||||
<IdentificationSection />
|
||||
<Text className="text-2xl ml-4">{t( "Other-Data" )}</Text>
|
||||
<OtherDataSection scrollToInput={scrollToInput} />
|
||||
{loading && <ActivityIndicator />}
|
||||
<View className="flex-row justify-evenly">
|
||||
<Button
|
||||
onPress={saveObservation}
|
||||
onPress={async ( ) => {
|
||||
setLoading( true );
|
||||
await saveObservation( );
|
||||
setLoading( false );
|
||||
setNextScreen( );
|
||||
}}
|
||||
testID="ObsEdit.saveButton"
|
||||
text={t( "SAVE" )}
|
||||
level="neutral"
|
||||
@@ -194,8 +230,13 @@ const ObsEdit = ( ): Node => {
|
||||
level="primary"
|
||||
text={t( "UPLOAD-OBSERVATION" )}
|
||||
testID="ObsEdit.uploadButton"
|
||||
onPress={saveAndUploadObservation}
|
||||
disabled={!isLoggedIn}
|
||||
onPress={async ( ) => {
|
||||
setLoading( true );
|
||||
await saveAndUploadObservation( );
|
||||
setLoading( false );
|
||||
setNextScreen( );
|
||||
}}
|
||||
disabled={!currentUser}
|
||||
/>
|
||||
</View>
|
||||
<AddEvidenceModal
|
||||
|
||||
@@ -65,7 +65,6 @@ const OtherDataSection = ( { scrollToInput }: Props ): Node => {
|
||||
<Button
|
||||
icon="earth"
|
||||
mode="text"
|
||||
onPress={() => console.log( "Pressed" )}
|
||||
textColor={colors.black}
|
||||
>
|
||||
{t( "Geoprivacy" )}
|
||||
@@ -84,7 +83,6 @@ const OtherDataSection = ( { scrollToInput }: Props ): Node => {
|
||||
<Button
|
||||
icon="pot"
|
||||
mode="text"
|
||||
onPress={() => console.log( "Pressed" )}
|
||||
textColor={colors.black}
|
||||
>
|
||||
{t( "Organism-is-wild" )}
|
||||
|
||||
82
src/components/ObsEdit/SaveDialog.js
Normal file
82
src/components/ObsEdit/SaveDialog.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
import { Text } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { Dialog, Portal } from "react-native-paper";
|
||||
|
||||
type Props = {
|
||||
showSaveDialog: boolean,
|
||||
discardChanges: Function
|
||||
}
|
||||
|
||||
const SaveDialog = ( {
|
||||
showSaveDialog,
|
||||
discardChanges
|
||||
}: Props ): Node => {
|
||||
const {
|
||||
saveObservation,
|
||||
observations
|
||||
} = useContext( ObsEditContext );
|
||||
|
||||
const singleObservationWarning = ( ) => (
|
||||
<>
|
||||
<Dialog.Content>
|
||||
<Text className="pt-10">{t( "You-have-unsaved-changes" )}</Text>
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button
|
||||
onPress={discardChanges}
|
||||
text={t( "Discard-Changes" )}
|
||||
level="warning"
|
||||
className="m-0.5"
|
||||
/>
|
||||
<Button
|
||||
onPress={saveObservation}
|
||||
text={t( "Save" )}
|
||||
level="primary"
|
||||
className="m-0.5"
|
||||
/>
|
||||
</Dialog.Actions>
|
||||
</>
|
||||
);
|
||||
|
||||
const multipleObservationWarning = ( ) => (
|
||||
<>
|
||||
<Dialog.Content>
|
||||
<Text className="pt-10">
|
||||
{t( "You-will-lose-all-existing-observations", { count: observations.length } )}
|
||||
</Text>
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button
|
||||
onPress={discardChanges}
|
||||
text={t( "Discard-X-Observations", { count: observations.length } )}
|
||||
level="warning"
|
||||
className="m-0.5"
|
||||
/>
|
||||
<Button
|
||||
onPress={saveObservation}
|
||||
text={t( "Cancel" )}
|
||||
level="primary"
|
||||
className="m-0.5"
|
||||
/>
|
||||
</Dialog.Actions>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Dialog visible={showSaveDialog} onDismiss={discardChanges}>
|
||||
{observations.length > 1
|
||||
? multipleObservationWarning( )
|
||||
: singleObservationWarning( )}
|
||||
</Dialog>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveDialog;
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Image, Pressable, View
|
||||
} from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
@@ -12,6 +13,7 @@ import colors from "styles/tailwindColors";
|
||||
|
||||
import ObsCardDetails from "./ObsCardDetails";
|
||||
import ObsCardStats from "./ObsCardStats";
|
||||
import UploadButton from "./UploadButton";
|
||||
|
||||
type Props = {
|
||||
// position of this item in a list of items; not ideal, but it allows us to
|
||||
@@ -45,13 +47,24 @@ const GridItem = ( {
|
||||
? Observation.projectUri( item )
|
||||
: { uri: Photo.displayLocalOrRemoteMediumPhoto( photo ) };
|
||||
|
||||
const showStats = ( ) => {
|
||||
if ( uri !== "project" && item.needsSync( ) ) {
|
||||
return (
|
||||
<View className="absolute bottom-0 right-0">
|
||||
<UploadButton observation={item} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <ObsCardStats item={item} view="grid" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={`w-1/2 px-4 py-2 ${( index || 0 ) % ( numColumns || 2 ) === 0 ? "pr-2" : "pl-2"}`}
|
||||
testID={`ObsList.gridItem.${item.uuid}`}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Navigate to observation details screen"
|
||||
accessibilityLabel={t( "Navigate-to-observation-details" )}
|
||||
>
|
||||
<View>
|
||||
{
|
||||
@@ -79,7 +92,7 @@ const GridItem = ( {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ObsCardStats item={item} view="grid" />
|
||||
{showStats( )}
|
||||
</View>
|
||||
<ObsCardDetails item={item} view="grid" />
|
||||
</Pressable>
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { Pressable, Text } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalObservations from "sharedHooks/useLocalObservations";
|
||||
|
||||
type Props = {
|
||||
numOfUnuploadedObs: number
|
||||
}
|
||||
|
||||
const LoggedOutCard = ( { numOfUnuploadedObs }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const LoggedOutCard = ( ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const localObservations = useLocalObservations( );
|
||||
const { unuploadedObsList } = localObservations;
|
||||
const numOfUnuploadedObs = unuploadedObsList?.length;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={( ) => navigation.navigate( "login" )}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t( "Navigate-to-login-screen" )}
|
||||
className="rounded-bl-3xl rounded-br-3xl bg-primary h-24 justify-center"
|
||||
>
|
||||
<Text
|
||||
testID="log-in-to-iNaturalist-text"
|
||||
|
||||
@@ -16,7 +16,7 @@ const LoginPrompt = ( ): Node => {
|
||||
<Button
|
||||
level="neutral"
|
||||
text={t( "LOG-IN-TO-INATURALIST" )}
|
||||
className="mt-5"
|
||||
className="py-1 mt-5"
|
||||
onPress={( ) => navigation.navigate( "login" )}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
// @flow
|
||||
|
||||
import { Image, Pressable, View } from "components/styledComponents";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Avatar } from "react-native-paper";
|
||||
import React from "react";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import Observation from "realmModels/Observation";
|
||||
import Photo from "realmModels/Photo";
|
||||
|
||||
import ObsCardDetails from "./ObsCardDetails";
|
||||
import ObsCardStats from "./ObsCardStats";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
import UploadButton from "./UploadButton";
|
||||
|
||||
type Props = {
|
||||
item: Object,
|
||||
@@ -20,20 +17,10 @@ type Props = {
|
||||
}
|
||||
|
||||
const ObsCard = ( { item, handlePress }: Props ): Node => {
|
||||
const [needsUpload, setNeedsUpload] = useState( false );
|
||||
const onPress = ( ) => handlePress( item );
|
||||
const realm = useRealm( );
|
||||
|
||||
const photo = item?.observationPhotos?.[0]?.photo;
|
||||
|
||||
useEffect( ( ) => {
|
||||
const markAsNeedsUpload = async ( ) => {
|
||||
const isUnsyncedObs = Observation.isUnsyncedObservation( realm, item );
|
||||
setNeedsUpload( isUnsyncedObs );
|
||||
};
|
||||
markAsNeedsUpload( );
|
||||
}, [item, realm] );
|
||||
|
||||
const obsListPhoto = photo ? (
|
||||
<Image
|
||||
source={{ uri: Photo.displayLocalOrRemoteSquarePhoto( photo ) }}
|
||||
@@ -50,7 +37,7 @@ const ObsCard = ( { item, handlePress }: Props ): Node => {
|
||||
className="flex-row my-2 mx-3 justify-between"
|
||||
testID={`ObsList.obsCard.${item.uuid}`}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel="Navigate to observation details screen"
|
||||
accessibilityLabel={t( "Navigate-to-observation-details" )}
|
||||
>
|
||||
<View className="flex-row shrink">
|
||||
{obsListPhoto}
|
||||
@@ -59,8 +46,8 @@ const ObsCard = ( { item, handlePress }: Props ): Node => {
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-items-center ml-2">
|
||||
{needsUpload
|
||||
? <Avatar.Icon size={40} icon="arrow-up-circle-outline" />
|
||||
{item.needsSync( )
|
||||
? <UploadButton observation={item} />
|
||||
: <ObsCardStats item={item} type="list" />}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -15,8 +15,8 @@ type Props = {
|
||||
}
|
||||
|
||||
const ObsCardStats = ( { item, type, view }: Props ): Node => {
|
||||
const numOfIds = item.identifications?.length || 0;
|
||||
const numOfComments = item.comments?.length || 0;
|
||||
const numOfIds = item.identifications?.length || "0";
|
||||
const numOfComments = item.comments?.length || "0";
|
||||
const qualityGrade = checkCamelAndSnakeCase( item, "qualityGrade" );
|
||||
|
||||
const setIconColor = ( ) => {
|
||||
@@ -35,11 +35,10 @@ const ObsCardStats = ( { item, type, view }: Props ): Node => {
|
||||
casual: t( "C" )
|
||||
};
|
||||
|
||||
// console.log( item.viewed, "viewed" );
|
||||
const renderIdRow = ( ) => (
|
||||
<View className="flex-row items-center mr-3">
|
||||
<Icon name="shield" color={setIconColor( )} size={14} />
|
||||
<Text className="mx-1" style={{ color: setIconColor( ) }}>{numOfIds || 0}</Text>
|
||||
<Text className="mx-1" style={{ color: setIconColor( ) }}>{numOfIds}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -51,7 +50,7 @@ const ObsCardStats = ( { item, type, view }: Props ): Node => {
|
||||
style={{ color: setIconColor( ) }}
|
||||
testID="ObsList.obsCard.commentCount"
|
||||
>
|
||||
{numOfComments || 0}
|
||||
{numOfComments}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useLoggedIn from "sharedHooks/useLoggedIn";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const ObsList = ( ): Node => {
|
||||
const realm = useRealm( );
|
||||
const loggedIn = useLoggedIn( );
|
||||
const currentUser = useCurrentUser( );
|
||||
|
||||
const updateParams = {
|
||||
// TODO: viewed = false is a param in the API v2 docs
|
||||
@@ -29,8 +29,7 @@ const ObsList = ( ): Node => {
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchObservationUpdates"],
|
||||
optsWithAuth => fetchObservationUpdates( updateParams, optsWithAuth ),
|
||||
{},
|
||||
{ enabled: !!loggedIn }
|
||||
{ enabled: !!currentUser }
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
|
||||
60
src/components/Observations/ObsListBottomSheet.js
Normal file
60
src/components/Observations/ObsListBottomSheet.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
|
||||
import BottomSheet from "components/SharedComponents/BottomSheet";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useLocalObservations from "sharedHooks/useLocalObservations";
|
||||
import useUploadStatus from "sharedHooks/useUploadStatus";
|
||||
|
||||
import LoginPrompt from "./LoginPrompt";
|
||||
import UploadProgressBar from "./UploadProgressBar";
|
||||
import UploadPrompt from "./UploadPrompt";
|
||||
|
||||
type Props = {
|
||||
hasScrolled: boolean
|
||||
}
|
||||
|
||||
const ObsListBottomSheet = ( { hasScrolled }: Props ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const { unuploadedObsList, allObsToUpload } = useLocalObservations( );
|
||||
const numOfUnuploadedObs = unuploadedObsList?.length;
|
||||
const { uploadInProgress, updateUploadStatus } = useUploadStatus( );
|
||||
|
||||
if ( numOfUnuploadedObs === 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( !currentUser ) {
|
||||
return (
|
||||
<BottomSheet hide={hasScrolled}>
|
||||
<LoginPrompt />
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
// FYI, this actually controls uploading, because the UploadProgressBar
|
||||
// calls useUploadObservations which immediately tries to upload, so just
|
||||
// rendering UploadProgressBar kicks off the upload
|
||||
if ( uploadInProgress ) {
|
||||
return (
|
||||
<UploadProgressBar
|
||||
unuploadedObsList={unuploadedObsList}
|
||||
allObsToUpload={allObsToUpload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if ( numOfUnuploadedObs > 0 && currentUser ) {
|
||||
return (
|
||||
<BottomSheet hide={hasScrolled}>
|
||||
<UploadPrompt
|
||||
uploadObservations={updateUploadStatus}
|
||||
numOfUnuploadedObs={numOfUnuploadedObs}
|
||||
updateUploadStatus={updateUploadStatus}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ObsListBottomSheet;
|
||||
@@ -4,37 +4,44 @@ import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { Animated } from "react-native";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
import LoggedOutCard from "./LoggedOutCard";
|
||||
import Toolbar from "./Toolbar";
|
||||
import UserCard from "./UserCard";
|
||||
|
||||
const { diffClamp } = Animated;
|
||||
|
||||
const HEADER_HEIGHT = 101;
|
||||
|
||||
type Props = {
|
||||
numOfUnuploadedObs: number,
|
||||
isLoggedIn: ?boolean,
|
||||
translateY: any,
|
||||
scrollY: any,
|
||||
setView: Function
|
||||
}
|
||||
|
||||
const ObsListHeader = ( {
|
||||
numOfUnuploadedObs, isLoggedIn, translateY, setView
|
||||
scrollY, setView
|
||||
}: Props ): Node => {
|
||||
if ( isLoggedIn === null ) {
|
||||
const currentUser = useCurrentUser( );
|
||||
const scrollYClamped = diffClamp( scrollY.current, 0, HEADER_HEIGHT );
|
||||
|
||||
if ( currentUser === null ) {
|
||||
return <View className="rounded-bl-3xl rounded-br-3xl bg-primary h-24" />;
|
||||
}
|
||||
|
||||
const translateY = scrollYClamped.interpolate( {
|
||||
inputRange: [0, HEADER_HEIGHT],
|
||||
// $FlowIgnore
|
||||
outputRange: [0, -HEADER_HEIGHT]
|
||||
} );
|
||||
|
||||
return (
|
||||
// $FlowIgnore
|
||||
<Animated.View style={[{ transform: [{ translateY }] }]}>
|
||||
<View className="rounded-bl-3xl rounded-br-3xl bg-primary h-24 justify-center">
|
||||
{isLoggedIn
|
||||
? <UserCard />
|
||||
: <LoggedOutCard numOfUnuploadedObs={numOfUnuploadedObs} />}
|
||||
</View>
|
||||
<Toolbar
|
||||
isLoggedIn={isLoggedIn}
|
||||
setView={setView}
|
||||
/>
|
||||
{currentUser
|
||||
? <UserCard />
|
||||
: <LoggedOutCard />}
|
||||
<Toolbar setView={setView} />
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,100 +1,48 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { searchObservations } from "api/observations";
|
||||
import BottomSheet from "components/SharedComponents/BottomSheet";
|
||||
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
|
||||
import { View } from "components/styledComponents";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useEffect, useMemo, useRef, useState
|
||||
useMemo, useRef, useState
|
||||
} from "react";
|
||||
import { Animated, Dimensions } from "react-native";
|
||||
import Observation from "realmModels/Observation";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useLocalObservations from "sharedHooks/useLocalObservations";
|
||||
import useLoggedIn from "sharedHooks/useLoggedIn";
|
||||
import useUploadStatus from "sharedHooks/useUploadStatus";
|
||||
|
||||
import EmptyList from "./EmptyList";
|
||||
import GridItem from "./GridItem";
|
||||
import useInfiniteScroll from "./hooks/useInfiniteScroll";
|
||||
import InfiniteScrollFooter from "./InfiniteScrollFooter";
|
||||
import LoginPrompt from "./LoginPrompt";
|
||||
import ObsCard from "./ObsCard";
|
||||
import ObsListBottomSheet from "./ObsListBottomSheet";
|
||||
import ObsListHeader from "./ObsListHeader";
|
||||
import UploadProgressBar from "./UploadProgressBar";
|
||||
import UploadPrompt from "./UploadPrompt";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
const { height } = Dimensions.get( "screen" );
|
||||
const FOOTER_HEIGHT = 75;
|
||||
const HEADER_HEIGHT = 101;
|
||||
const BUTTON_ROW_HEIGHT = 50;
|
||||
|
||||
// using flatListHeight to make the bottom sheet snap points work when the flatlist
|
||||
// has only a few items and isn't scrollable
|
||||
const flatListHeight = height - (
|
||||
HEADER_HEIGHT + FOOTER_HEIGHT + BUTTON_ROW_HEIGHT
|
||||
);
|
||||
|
||||
const ObservationViews = ( ): Node => {
|
||||
const localObservations = useLocalObservations( );
|
||||
const realm = useRealm( );
|
||||
const [view, setView] = useState( "list" );
|
||||
const navigation = useNavigation( );
|
||||
const isLoggedIn = useLoggedIn( );
|
||||
const { observationList, unuploadedObsList } = localObservations;
|
||||
const numOfUnuploadedObs = unuploadedObsList?.length;
|
||||
|
||||
const currentUser = realm.objects( "User" ).filtered( "signedIn == true" )[0];
|
||||
const [idBelow, setIdBelow] = useState( null );
|
||||
|
||||
const params = {
|
||||
user_id: currentUser?.id,
|
||||
per_page: 10,
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
|
||||
if ( idBelow ) {
|
||||
// $FlowIgnore
|
||||
params.id_below = idBelow;
|
||||
} else {
|
||||
// $FlowIgnore
|
||||
params.page = 1;
|
||||
}
|
||||
|
||||
const {
|
||||
data: observations,
|
||||
isLoading
|
||||
} = useAuthenticatedQuery(
|
||||
["searchObservations", idBelow],
|
||||
optsWithAuth => searchObservations( params, optsWithAuth ),
|
||||
{},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: !!isLoggedIn
|
||||
}
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( observations ) {
|
||||
Observation.updateLocalObservationsFromRemote( realm, observations );
|
||||
}
|
||||
}, [realm, observations] );
|
||||
|
||||
// eslint-disable-next-line
|
||||
const currentUser = useCurrentUser( );
|
||||
const { observationList } = localObservations;
|
||||
const [hasScrolled, setHasScrolled] = useState( false );
|
||||
|
||||
const { diffClamp } = Animated;
|
||||
const headerHeight = 120;
|
||||
const [idBelow, setIdBelow] = useState( null );
|
||||
const isLoading = useInfiniteScroll( idBelow );
|
||||
|
||||
// basing collapsible sticky header code off the example in this article
|
||||
// https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3
|
||||
const scrollY = useRef( new Animated.Value( 0 ) );
|
||||
const scrollYClamped = diffClamp( scrollY.current, 0, headerHeight );
|
||||
|
||||
const translateY = scrollYClamped.interpolate( {
|
||||
inputRange: [0, headerHeight],
|
||||
// $FlowIgnore
|
||||
outputRange: [0, -headerHeight]
|
||||
} );
|
||||
|
||||
const translateYNumber = useRef();
|
||||
|
||||
translateY.addListener( ( { value } ) => {
|
||||
translateYNumber.current = value;
|
||||
} );
|
||||
|
||||
const handleScroll = Animated.event(
|
||||
[
|
||||
@@ -117,19 +65,6 @@ const ObservationViews = ( ): Node => {
|
||||
useNativeDriver: true
|
||||
}
|
||||
);
|
||||
const { uploadInProgress, updateUploadStatus } = useUploadStatus( );
|
||||
const { allObsToUpload } = localObservations;
|
||||
|
||||
const { height } = Dimensions.get( "screen" );
|
||||
const FOOTER_HEIGHT = 75;
|
||||
const HEADER_HEIGHT = 101;
|
||||
const BUTTON_ROW_HEIGHT = 50;
|
||||
|
||||
// using flatListHeight to make the bottom sheet snap points work when the flatlist
|
||||
// has only a few items and isn't scrollable
|
||||
const flatListHeight = height - (
|
||||
HEADER_HEIGHT + FOOTER_HEIGHT + BUTTON_ROW_HEIGHT
|
||||
);
|
||||
|
||||
const navToObsDetails = async observation => {
|
||||
const { uuid } = observation;
|
||||
@@ -140,9 +75,15 @@ const ObservationViews = ( ): Node => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ( { item } ) => (
|
||||
<ObsCard item={item} handlePress={navToObsDetails} />
|
||||
);
|
||||
const renderEmptyState = ( ) => {
|
||||
if ( ( currentUser === false )
|
||||
|| ( !isLoading && observationList.length === 0 ) ) {
|
||||
return <EmptyList />;
|
||||
}
|
||||
return <View />;
|
||||
};
|
||||
|
||||
const renderItem = ( { item } ) => <ObsCard item={item} handlePress={navToObsDetails} />;
|
||||
|
||||
const renderGridItem = ( { item, index } ) => (
|
||||
<GridItem
|
||||
@@ -152,46 +93,15 @@ const ObservationViews = ( ): Node => {
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = ( ) => {
|
||||
if ( ( isLoggedIn === false )
|
||||
|| ( !isLoading && observationList.length === 0 ) ) {
|
||||
return <EmptyList />;
|
||||
}
|
||||
return <View />;
|
||||
};
|
||||
|
||||
const renderBottomSheet = ( ) => {
|
||||
if ( isLoggedIn === false ) {
|
||||
return (
|
||||
<BottomSheet hide={hasScrolled}>
|
||||
<LoginPrompt />
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
if ( uploadInProgress ) {
|
||||
return (
|
||||
<UploadProgressBar
|
||||
unuploadedObsList={unuploadedObsList}
|
||||
allObsToUpload={allObsToUpload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if ( numOfUnuploadedObs > 0 && isLoggedIn ) {
|
||||
return (
|
||||
<BottomSheet hide={hasScrolled}>
|
||||
<UploadPrompt
|
||||
uploadObservations={updateUploadStatus}
|
||||
numOfUnuploadedObs={numOfUnuploadedObs}
|
||||
updateUploadStatus={updateUploadStatus}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const renderHeader = useMemo( ( ) => (
|
||||
<ObsListHeader
|
||||
scrollY={scrollY}
|
||||
setView={setView}
|
||||
/>
|
||||
), [scrollY] );
|
||||
|
||||
const renderFooter = ( ) => {
|
||||
if ( isLoggedIn === false ) { return <View />; }
|
||||
if ( currentUser === false ) { return <View />; }
|
||||
return (
|
||||
<InfiniteScrollFooter
|
||||
view={view}
|
||||
@@ -200,14 +110,7 @@ const ObservationViews = ( ): Node => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = useMemo( ( ) => (
|
||||
<ObsListHeader
|
||||
numOfUnuploadedObs={numOfUnuploadedObs}
|
||||
isLoggedIn={isLoggedIn}
|
||||
translateY={translateY}
|
||||
setView={setView}
|
||||
/>
|
||||
), [isLoggedIn, translateY, numOfUnuploadedObs] );
|
||||
const renderBottomSheet = ( ) => <ObsListBottomSheet hasScrolled={hasScrolled} />;
|
||||
|
||||
const renderItemSeparator = ( ) => <View className="border border-border" />;
|
||||
|
||||
@@ -241,7 +144,7 @@ const ObservationViews = ( ): Node => {
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.1}
|
||||
/>
|
||||
{numOfUnuploadedObs > 0 && renderBottomSheet( )}
|
||||
{renderBottomSheet( )}
|
||||
</ViewWithFooter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
// @flow
|
||||
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
type Props = {
|
||||
isLoggedIn: ?boolean,
|
||||
setView: Function
|
||||
}
|
||||
|
||||
const Toolbar = ( {
|
||||
isLoggedIn,
|
||||
setView
|
||||
}: Props ): Node => (
|
||||
<View className="py-5 flex-row justify-between bg-white">
|
||||
{isLoggedIn ? (
|
||||
// TODO: syncing observations probably involves uploading, then downloading
|
||||
// but not entirely sure what this button is supposed to do in what order
|
||||
<Pressable
|
||||
onPress={( ) => console.log( "sync observations" )}
|
||||
className="mx-3"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="sync" size={30} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View className="mx-3" />
|
||||
)}
|
||||
<View className="flex flex-row flex-nowrap mx-3">
|
||||
<Pressable
|
||||
onPress={( ) => setView( "list" )}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="format-list-bulleted" size={30} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={( ) => setView( "grid" )}
|
||||
testID="ObsList.toggleGridView"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="grid-view" size={30} />
|
||||
</Pressable>
|
||||
const Toolbar = ( { setView }: Props ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const obsEditContext = useContext( ObsEditContext );
|
||||
const loading = obsEditContext?.loading;
|
||||
const syncObservations = obsEditContext?.syncObservations;
|
||||
|
||||
return (
|
||||
<View className="py-5 flex-row justify-between bg-white">
|
||||
{currentUser ? (
|
||||
<Pressable
|
||||
onPress={syncObservations}
|
||||
className="mx-3"
|
||||
accessibilityRole="button"
|
||||
disabled={loading}
|
||||
>
|
||||
<IconMaterial name="sync" size={30} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View className="mx-3" />
|
||||
)}
|
||||
{loading && <ActivityIndicator />}
|
||||
<View className="flex-row mx-3">
|
||||
<Pressable
|
||||
onPress={( ) => setView( "list" )}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="format-list-bulleted" size={30} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={( ) => setView( "grid" )}
|
||||
testID="ObsList.toggleGridView"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="grid-view" size={30} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
||||
32
src/components/Observations/UploadButton.js
Normal file
32
src/components/Observations/UploadButton.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useContext } from "react";
|
||||
import { IconButton } from "react-native-paper";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
type Props = {
|
||||
observation: Object
|
||||
}
|
||||
|
||||
const UploadButton = ( { observation }: Props ): Node => {
|
||||
const obsEditContext = useContext( ObsEditContext );
|
||||
const uploadObservation = obsEditContext?.uploadObservation;
|
||||
const setLoading = obsEditContext?.setLoading;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size={40}
|
||||
icon="arrow-up-circle-outline"
|
||||
iconColor={colors.borderGray}
|
||||
onPress={async ( ) => {
|
||||
setLoading( true );
|
||||
await uploadObservation( observation );
|
||||
setLoading( false );
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadButton;
|
||||
@@ -24,10 +24,7 @@ const UploadProgressBar = ( { unuploadedObsList, allObsToUpload }: Props ): Node
|
||||
|
||||
const progressFraction = calculateProgress( );
|
||||
|
||||
const {
|
||||
handleClosePress,
|
||||
status
|
||||
} = useUploadObservations( allObsToUpload );
|
||||
const { handleClosePress } = useUploadObservations( allObsToUpload );
|
||||
|
||||
const sheetRef = useRef( null );
|
||||
|
||||
@@ -36,24 +33,6 @@ const UploadProgressBar = ( { unuploadedObsList, allObsToUpload }: Props ): Node
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
const noHandle = ( ) => <></>;
|
||||
|
||||
const showError = ( ) => {
|
||||
if ( status === "failure" ) {
|
||||
return (
|
||||
<Text style={textStyles.whiteText} variant="titleMedium">
|
||||
{t( "Error-Couldnt-Complete-Upload" )}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if ( status === "photoFailure" ) {
|
||||
return (
|
||||
<Text style={textStyles.whiteText} variant="titleMedium">
|
||||
{t( "Error-Couldnt-Upload-Photo" )}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={sheetRef}
|
||||
@@ -76,7 +55,6 @@ const UploadProgressBar = ( { unuploadedObsList, allObsToUpload }: Props ): Node
|
||||
style={viewStyles.progressBar}
|
||||
color={colors.white}
|
||||
/>
|
||||
{showError( )}
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ const UploadPrompt = ( {
|
||||
<Button
|
||||
level="neutral"
|
||||
text={t( "UPLOAD-X-OBSERVATIONS", { count: numOfUnuploadedObs } )}
|
||||
className="mt-5"
|
||||
className="py-1 mt-5"
|
||||
onPress={( ) => {
|
||||
updateUploadStatus( );
|
||||
uploadObservations( );
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import UserIcon from "components/SharedComponents/UserIcon";
|
||||
import { Pressable, Text, View } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import User from "realmModels/User";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
@@ -14,14 +14,13 @@ import colors from "styles/tailwindColors";
|
||||
const UserCard = ( ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const { t } = useTranslation( );
|
||||
if ( !currentUser ) { return <View className="flex-row mx-5 items-center" />; }
|
||||
const navToUserProfile = ( ) => navigation.navigate( "UserProfile", { userId: currentUser.id } );
|
||||
|
||||
const uri = User.uri( currentUser );
|
||||
|
||||
return (
|
||||
<View className="flex-row mx-5 items-center">
|
||||
<View className="flex-row px-5 items-center rounded-bl-3xl rounded-br-3xl bg-primary h-24">
|
||||
{uri && <UserIcon uri={uri} />}
|
||||
<View className="ml-3">
|
||||
<Text className="color-white my-1">{User.userHandle( currentUser )}</Text>
|
||||
@@ -31,7 +30,7 @@ const UserCard = ( ): Node => {
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={navToUserProfile}
|
||||
className="absolute right-0"
|
||||
className="absolute right-5"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<IconMaterial name="edit" size={30} color={colors.white} />
|
||||
|
||||
48
src/components/Observations/hooks/useInfiniteScroll.js
Normal file
48
src/components/Observations/hooks/useInfiniteScroll.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
|
||||
import { searchObservations } from "api/observations";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect } from "react";
|
||||
import Observation from "realmModels/Observation";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const useInfiniteScroll = ( idBelow: ?number ): boolean => {
|
||||
const realm = useRealm( );
|
||||
const currentUser = useCurrentUser( );
|
||||
|
||||
const params = {
|
||||
user_id: currentUser?.id,
|
||||
per_page: 10,
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
|
||||
if ( idBelow ) {
|
||||
// $FlowIgnore
|
||||
params.id_below = idBelow;
|
||||
} else {
|
||||
// $FlowIgnore
|
||||
params.page = 1;
|
||||
}
|
||||
|
||||
const {
|
||||
data: observations,
|
||||
isLoading
|
||||
} = useAuthenticatedQuery(
|
||||
["searchObservations", idBelow],
|
||||
optsWithAuth => searchObservations( params, optsWithAuth ),
|
||||
{
|
||||
enabled: !!currentUser
|
||||
}
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
Observation.upsertRemoteObservations( observations, realm );
|
||||
}, [realm, observations] );
|
||||
|
||||
return currentUser ? isLoading : false;
|
||||
};
|
||||
|
||||
export default useInfiniteScroll;
|
||||
@@ -1,13 +1,11 @@
|
||||
// @flow
|
||||
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { Text } from "components/styledComponents";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useContext } from "react";
|
||||
import RNPickerSelect from "react-native-picker-select";
|
||||
|
||||
import usePhotoAlbums from "./hooks/usePhotoAlbums";
|
||||
|
||||
const FONT_SIZE = 20;
|
||||
|
||||
const inputStyle = {
|
||||
@@ -20,18 +18,25 @@ const pickerTextStyle = {
|
||||
iconContainer: { right: 20 }
|
||||
};
|
||||
|
||||
const PhotoAlbumPicker = ( ): Node => {
|
||||
type Props = {
|
||||
albums: ?Array<Object>
|
||||
}
|
||||
|
||||
const PhotoAlbumPicker = ( { albums }: Props ): Node => {
|
||||
const { setAlbum } = useContext( ObsEditContext );
|
||||
|
||||
const changeAlbum = newAlbum => setAlbum( newAlbum !== "All" ? newAlbum : null );
|
||||
|
||||
const albums = usePhotoAlbums( );
|
||||
const noAlbums = albums.length <= 1;
|
||||
if ( !albums ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noAlbums = albums?.length <= 1;
|
||||
|
||||
const placeholder = {};
|
||||
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
const icon = ( ) => ( noAlbums ? <View /> : <Text className="text-2xl">⌄</Text> );
|
||||
const icon = ( ) => !noAlbums && <Text className="text-2xl">⌄</Text>;
|
||||
|
||||
return (
|
||||
<RNPickerSelect
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import useCameraRollPhotos from "components/PhotoImporter/hooks/useCameraRollPhotos";
|
||||
import usePhotoAlbums from "components/PhotoImporter/hooks/usePhotoAlbums";
|
||||
import PhotoAlbumPicker from "components/PhotoImporter/PhotoAlbumPicker";
|
||||
import PhotoGalleryImage from "components/PhotoImporter/PhotoGalleryImage";
|
||||
import Button from "components/SharedComponents/Buttons/Button";
|
||||
import ViewNoFooter from "components/SharedComponents/ViewNoFooter";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
@@ -15,9 +19,6 @@ import {
|
||||
} from "react-native";
|
||||
import { Snackbar } from "react-native-paper";
|
||||
|
||||
import useCameraRollPhotos from "./hooks/useCameraRollPhotos";
|
||||
import PhotoGalleryImage from "./PhotoGalleryImage";
|
||||
|
||||
const MAX_PHOTOS_ALLOWED = 20;
|
||||
|
||||
const options = {
|
||||
@@ -42,6 +43,8 @@ const PhotoGallery = ( ): Node => {
|
||||
// they are needed and not just when this provider initializes
|
||||
const [canRequestPhotos, setCanRequestPhotos] = useState( false );
|
||||
|
||||
const albums = usePhotoAlbums( );
|
||||
|
||||
const {
|
||||
fetchingPhotos,
|
||||
photos: galleryPhotos
|
||||
@@ -62,10 +65,12 @@ const PhotoGallery = ( ): Node => {
|
||||
const { params } = useRoute( );
|
||||
const skipGroupPhotos = params?.skipGroupPhotos;
|
||||
|
||||
const selectedPhotos = galleryPhotos.filter( photo => galleryUris?.includes(
|
||||
const allPhotos: Array<Object> = Object.values( photoGallery ).flat( );
|
||||
|
||||
const selectedPhotos = allPhotos.filter( photo => galleryUris?.includes(
|
||||
photo?.image?.uri
|
||||
) );
|
||||
const selectedEvidenceToAdd = galleryPhotos.filter(
|
||||
const selectedEvidenceToAdd = allPhotos.filter(
|
||||
photo => evidenceToAdd?.includes( photo?.image?.uri )
|
||||
);
|
||||
|
||||
@@ -164,20 +169,19 @@ const PhotoGallery = ( ): Node => {
|
||||
const fetchMorePhotos = ( ) => setIsScrolling( true );
|
||||
|
||||
const navToNextScreen = async ( ) => {
|
||||
const navToObsEdit = ( ) => navigation.navigate( "ObsEdit", { lastScreen: "PhotoGallery" } );
|
||||
if ( !selectedPhotos ) return;
|
||||
const uris = selectedPhotos.map( galleryPhoto => galleryPhoto.image.uri );
|
||||
setGalleryUris( uris );
|
||||
if ( skipGroupPhotos ) {
|
||||
// add any newly selected photos
|
||||
// to an existing observation after navigating from ObsEdit
|
||||
addGalleryPhotosToCurrentObservation( selectedEvidenceToAdd );
|
||||
navigation.navigate( "ObsEdit", { lastScreen: "PhotoGallery" } );
|
||||
navToObsEdit( );
|
||||
return;
|
||||
}
|
||||
if ( selectedPhotos.length === 1 ) {
|
||||
// create a new observation and skip group photos screen
|
||||
createObservationFromGallery( selectedPhotos[0] );
|
||||
navigation.navigate( "ObsEdit", { lastScreen: "PhotoGallery" } );
|
||||
navToObsEdit( );
|
||||
return;
|
||||
}
|
||||
navigation.navigate( "GroupPhotos", { selectedPhotos } );
|
||||
@@ -208,6 +212,14 @@ const PhotoGallery = ( ): Node => {
|
||||
setPhotoOptions( newOptions );
|
||||
}, [album] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const headerTitle = ( ) => <PhotoAlbumPicker albums={albums} />;
|
||||
|
||||
navigation.setOptions( {
|
||||
headerTitle
|
||||
} );
|
||||
}, [navigation, albums] );
|
||||
|
||||
return (
|
||||
<ViewNoFooter>
|
||||
<FlatList
|
||||
@@ -219,7 +231,7 @@ const PhotoGallery = ( ): Node => {
|
||||
renderItem={renderImage}
|
||||
onEndReached={fetchMorePhotos}
|
||||
testID="PhotoGallery.list"
|
||||
ListEmptyComponent={renderEmptyList( )}
|
||||
ListEmptyComponent={renderEmptyList}
|
||||
extraData={rerenderList}
|
||||
/>
|
||||
{ totalSelected > 0 && (
|
||||
|
||||
@@ -71,7 +71,7 @@ const useCameraRollPhotos = (
|
||||
fetchingPhotos: false
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't get photos from gallery" );
|
||||
console.warn( e, "couldn't get photos from gallery" );
|
||||
}
|
||||
}, [photoFetchStatus, options] );
|
||||
|
||||
|
||||
@@ -4,42 +4,34 @@ import { CameraRoll } from "@react-native-camera-roll/camera-roll";
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const usePhotoAlbums = ( ): Array<Object> => {
|
||||
const usePhotoAlbums = ( ): ?Array<Object> => {
|
||||
const cameraRoll = useMemo( ( ) => [{
|
||||
label: t( "Camera-Roll" ),
|
||||
value: "All",
|
||||
key: "camera roll"
|
||||
}], [] );
|
||||
|
||||
const [photoAlbums, setPhotoAlbums] = useState( cameraRoll );
|
||||
const [photoAlbums, setPhotoAlbums] = useState( null );
|
||||
|
||||
useEffect( ( ) => {
|
||||
let isCurrent = true;
|
||||
|
||||
const fetchAlbums = async ( ) => {
|
||||
const albumsToDisplay = cameraRoll;
|
||||
|
||||
try {
|
||||
const names = cameraRoll;
|
||||
const albums = await CameraRoll.getAlbums( { assetType: "Photos" } );
|
||||
|
||||
if ( albums && albums.length > 0 ) { // attempt to fix error on android
|
||||
albums.forEach( ( { count, title } ) => {
|
||||
if ( count > 0 && title !== "Screenshots" ) { // remove screenshots from gallery
|
||||
names.push( { label: title, value: title, key: title } );
|
||||
}
|
||||
} );
|
||||
}
|
||||
if ( !isCurrent ) { return; }
|
||||
setPhotoAlbums( names );
|
||||
const filteredAlbums = albums.filter( a => a.title !== "Screenshots" && a.count > 0 );
|
||||
filteredAlbums.forEach( ( { title } ) => {
|
||||
albumsToDisplay.push( { label: title, value: title, key: title } );
|
||||
} );
|
||||
|
||||
setPhotoAlbums( albumsToDisplay );
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't fetch photo albums" );
|
||||
setPhotoAlbums( albumsToDisplay );
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlbums( );
|
||||
|
||||
return ( ) => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [cameraRoll] );
|
||||
|
||||
return photoAlbums;
|
||||
|
||||
@@ -27,6 +27,7 @@ const ProjectObservations = ( { id }: Props ): React.Node => {
|
||||
const renderGridItem = ( { item } ) => (
|
||||
<GridItem item={item} handlePress={navToObsDetails} uri="project" />
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={observations}
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
|
||||
import { searchProjects } from "api/projects";
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useUserLocation from "sharedHooks/useUserLocation";
|
||||
import { viewStyles } from "styles/projects/projects";
|
||||
|
||||
import ProjectList from "./ProjectList";
|
||||
|
||||
type Props = {
|
||||
memberId: number
|
||||
}
|
||||
|
||||
const ProjectTabs = ( { memberId }: Props ): React.Node => {
|
||||
const userJoined = { member_id: memberId };
|
||||
const [apiParams, setApiParams] = React.useState( userJoined );
|
||||
const ProjectTabs = ( ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const memberId = currentUser?.id;
|
||||
const [apiParams, setApiParams] = React.useState( { } );
|
||||
|
||||
const latLng = useUserLocation( );
|
||||
|
||||
@@ -35,7 +34,15 @@ const ProjectTabs = ( { memberId }: Props ): React.Node => {
|
||||
};
|
||||
|
||||
const fetchFeaturedProjects = ( ) => setApiParams( { features: true } );
|
||||
const fetchUserJoinedProjects = ( ) => setApiParams( userJoined );
|
||||
const fetchUserJoinedProjects = useCallback( ( ) => setApiParams(
|
||||
{ member_id: memberId }
|
||||
), [memberId] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( memberId ) {
|
||||
fetchUserJoinedProjects( );
|
||||
}
|
||||
}, [memberId, fetchUserJoinedProjects] );
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
// @flow
|
||||
|
||||
import { fetchUserMe } from "api/users";
|
||||
import InputField from "components/SharedComponents/InputField";
|
||||
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
|
||||
import * as React from "react";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import ProjectSearch from "./ProjectSearch";
|
||||
import ProjectTabs from "./ProjectTabs";
|
||||
|
||||
const Projects = ( ): React.Node => {
|
||||
const [q, setQ] = React.useState( "" );
|
||||
const Projects = ( ): Node => {
|
||||
const [q, setQ] = useState( "" );
|
||||
const [view, setView] = useState( "tabs" );
|
||||
|
||||
const clearSearch = ( ) => setQ( "" );
|
||||
|
||||
const {
|
||||
data: user
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchUserMe"],
|
||||
optsWithAuth => fetchUserMe( { }, optsWithAuth )
|
||||
);
|
||||
|
||||
const memberId = user?.id;
|
||||
useEffect( ( ) => {
|
||||
if ( q.length > 0 ) {
|
||||
setView( "search" );
|
||||
} else {
|
||||
setView( "tabs" );
|
||||
}
|
||||
}, [q] );
|
||||
|
||||
return (
|
||||
<ViewWithFooter testID="Projects">
|
||||
@@ -32,11 +31,9 @@ const Projects = ( ): React.Node => {
|
||||
type="none"
|
||||
testID="ProjectSearch.input"
|
||||
/>
|
||||
{/* TODO: make project search a separate screen or a modal?
|
||||
not sure what the final designs will look like but unlikely
|
||||
tabs and search will both be on the same screen */}
|
||||
{memberId && <ProjectTabs memberId={memberId} />}
|
||||
<ProjectSearch q={q} clearSearch={clearSearch} />
|
||||
{view === "tabs"
|
||||
? <ProjectTabs />
|
||||
: <ProjectSearch q={q} clearSearch={clearSearch} />}
|
||||
</ViewWithFooter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchUserMe, updateUsers } from "api/users";
|
||||
import { updateUsers } from "api/users";
|
||||
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
View
|
||||
} from "react-native";
|
||||
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useUserMe from "sharedHooks/useUserMe";
|
||||
import { textStyles, viewStyles } from "styles/settings/settings";
|
||||
|
||||
import SettingsAccount from "./SettingsAccount";
|
||||
@@ -150,14 +150,7 @@ const Settings = ( { children: _children }: Props ): Node => {
|
||||
const [settings, setSettings] = useState( {} );
|
||||
const [isSaving, setIsSaving] = useState( false );
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
refetch: refetchUserMe
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchUserMe"],
|
||||
optsWithAuth => fetchUserMe( { }, optsWithAuth )
|
||||
);
|
||||
const { remoteUser: user, isLoading, refetchUserMe } = useUserMe( );
|
||||
|
||||
const queryClient = useQueryClient( );
|
||||
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
// @flow
|
||||
|
||||
import CameraOptionsModal from "components/CameraOptionsModal";
|
||||
import AddObsModal from "components/AddObsModal";
|
||||
import Modal from "components/SharedComponents/Modal";
|
||||
import * as React from "react";
|
||||
import { Pressable } from "react-native";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
|
||||
const CameraOptionsButton = ( ): React.Node => {
|
||||
const AddObsButton = ( ): React.Node => {
|
||||
const [showModal, setModal] = React.useState( false );
|
||||
|
||||
const openModal = React.useCallback( ( ) => setModal( true ), [] );
|
||||
const closeModal = React.useCallback( ( ) => setModal( false ), [] );
|
||||
|
||||
const navToCameraOptions = ( ) => openModal( );
|
||||
const navToAddObs = ( ) => openModal( );
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
showModal={showModal}
|
||||
closeModal={closeModal}
|
||||
modal={<CameraOptionsModal closeModal={closeModal} />}
|
||||
modal={<AddObsModal closeModal={closeModal} />}
|
||||
/>
|
||||
<Pressable
|
||||
testID="camera-options-button"
|
||||
onPress={navToCameraOptions}
|
||||
accessibilityRole="link"
|
||||
>
|
||||
<Pressable testID="camera-options-button" onPress={navToAddObs} accessibilityRole="link">
|
||||
<IconMaterial name="add-circle" size={30} />
|
||||
</Pressable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraOptionsButton;
|
||||
export default AddObsButton;
|
||||
@@ -6,7 +6,7 @@ import { Platform } from "react-native";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import { viewStyles } from "styles/sharedComponents/footer";
|
||||
|
||||
import CameraOptionsButton from "./Buttons/CameraOptionsButton";
|
||||
import AddObsButton from "./Buttons/AddObsButton";
|
||||
|
||||
const Footer = ( ): React.Node => {
|
||||
const navigation = useNavigation( );
|
||||
@@ -30,7 +30,7 @@ const Footer = ( ): React.Node => {
|
||||
<Pressable onPress={navToExplore} accessibilityRole="link">
|
||||
<IconMaterial name="language" size={30} />
|
||||
</Pressable>
|
||||
<CameraOptionsButton />
|
||||
<AddObsButton />
|
||||
<Pressable onPress={navToObsList} accessibilityRole="link">
|
||||
<IconMaterial name="person" size={30} />
|
||||
</Pressable>
|
||||
|
||||
@@ -21,6 +21,7 @@ const KebabMenu = ( { children, visible, setVisible }: Props ): Node => {
|
||||
onPress={openMenu}
|
||||
icon="dots-horizontal"
|
||||
textColor={colors.logInGray}
|
||||
testID="KebabMenu.Button"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ type Props = {
|
||||
// future we might want to extend this to always show a custom view before
|
||||
// asking the user for a permission.
|
||||
const PermissionGate = ( { children, permission, isIOS }: Props ): Node => {
|
||||
console.log( "PermissionGate" );
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = useState(
|
||||
@@ -54,7 +53,6 @@ const PermissionGate = ( { children, permission, isIOS }: Props ): Node => {
|
||||
|
||||
const requestiOSPermissions = async () => {
|
||||
const r = await request( permission );
|
||||
console.log( "iOS permission", permission, r );
|
||||
|
||||
if ( r === RESULTS.GRANTED ) {
|
||||
setResult( "granted" );
|
||||
|
||||
@@ -54,7 +54,7 @@ const SoundRecorder = ( ): Node => {
|
||||
} );
|
||||
setUri( cachedFile );
|
||||
} catch ( e ) {
|
||||
console.log( "couldn't start sound recorder:", e );
|
||||
console.warn( "couldn't start sound recorder:", e );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ const SoundRecorder = ( ): Node => {
|
||||
await audioRecorderPlayer.resumeRecorder( );
|
||||
setStatus( "recording" );
|
||||
} catch ( e ) {
|
||||
console.log( "couldn't resume sound recorder:", e );
|
||||
console.warn( "couldn't resume sound recorder:", e );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ const SoundRecorder = ( ): Node => {
|
||||
recordSecs: 0
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.log( "couldn't stop sound recorder:", e );
|
||||
console.warn( "couldn't stop sound recorder:", e );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ const SoundRecorder = ( ): Node => {
|
||||
} );
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.log( "can't play recording: ", e );
|
||||
console.warn( "can't play recording: ", e );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const UserProfile = ( ): React.Node => {
|
||||
<Button
|
||||
level="primary"
|
||||
text={t( "Messages" )}
|
||||
onPress={( ) => console.log( "open messages" )}
|
||||
onPress={( ) => navigation.navigate( "Messages" )}
|
||||
testID="UserProfile.messagesButton"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -33,6 +33,9 @@ All = All
|
||||
|
||||
All-observations = All observations
|
||||
|
||||
# license code
|
||||
all-rights-reserved = all rights reserved
|
||||
|
||||
Amphibians = Amphibians
|
||||
|
||||
Arachnids = Arachnids
|
||||
@@ -60,6 +63,9 @@ Blocked-Users = Blocked Users
|
||||
# Quality grade option: Casual (shortened for My Observations view)
|
||||
C = C
|
||||
|
||||
# Accessible label for the camera button
|
||||
camera-button-label-switch-camera = Use the device's other camera.
|
||||
|
||||
Camera-Roll = Camera Roll
|
||||
|
||||
Cancel = Cancel
|
||||
@@ -114,6 +120,13 @@ DELETE-X-OBSERVATIONS = DELETE {$count ->
|
||||
|
||||
Description-Tags = Description/Tags
|
||||
|
||||
Discard-Changes = Discard Changes
|
||||
|
||||
Discard-X-Observations = {$count ->
|
||||
[one] Discard Observation
|
||||
*[other] Discard Observations
|
||||
}
|
||||
|
||||
Display = Display
|
||||
|
||||
Display-Name = Display Name
|
||||
@@ -146,6 +159,12 @@ Finish = Finish
|
||||
|
||||
Fish = Fish
|
||||
|
||||
# Accessible label for the flash button, when flas is turned on
|
||||
flash-button-label-flash = The flash is turned on. Press here to disable it.
|
||||
|
||||
# Accessible label for the flash button, when flas is turned off
|
||||
flash-button-label-flash-off = The flash is turned off. Press here to enable it.
|
||||
|
||||
Following = Following
|
||||
|
||||
# Forgot password link
|
||||
@@ -276,6 +295,10 @@ Native = Native
|
||||
|
||||
Navigate-to-login-screen = Navigate to login screen
|
||||
|
||||
Navigate-to-observation-details = Navigate to observation details screen
|
||||
|
||||
Navigate-to-taxon-details = Navigate to taxon details
|
||||
|
||||
# Header for nearby projects
|
||||
Nearby = Nearby
|
||||
|
||||
@@ -300,6 +323,9 @@ No-Location = No Location
|
||||
|
||||
No-photos-found = No photos found. If this is your first time opening the app and giving permissions, try restarting the app.
|
||||
|
||||
# license code
|
||||
no-rights-reserved = no rights reserved
|
||||
|
||||
# Header for observation description on observation detail
|
||||
Notes = Notes
|
||||
|
||||
@@ -309,6 +335,8 @@ Obscured = Obscured
|
||||
|
||||
Observation = Observation
|
||||
|
||||
Observation-Attribution = Observation © {$attribution} · {$licenseCode}
|
||||
|
||||
Observations = Observations
|
||||
|
||||
Open = Open
|
||||
@@ -467,6 +495,8 @@ Revoke = Revoke
|
||||
# Quality grade option: Research Grade (shortened for My Observations view)
|
||||
RG = RG
|
||||
|
||||
Save = Save
|
||||
|
||||
Search-for-a-location = Search for a location
|
||||
|
||||
Search-for-a-project = Search for a project
|
||||
@@ -492,6 +522,9 @@ Sign-Up = Sign Up
|
||||
# Header for a section showing taxa similar to a single taxon
|
||||
SIMILAR-SPECIES-header = SIMILAR SPECIES
|
||||
|
||||
# license code
|
||||
some-rights-reserved = some rights reserved
|
||||
|
||||
Sort-By = Sort By
|
||||
|
||||
Sort-by = Sort by
|
||||
@@ -608,6 +641,17 @@ Record-a-sound = Record a sound
|
||||
# (e.g. permission to access the camera) but the user denied the permission.
|
||||
You-denied-iNaturalist-permission-to-do-that = You denied iNaturalist permission to do that
|
||||
|
||||
You-have-unsaved-changes = You have unsaved changes. Would you like to save this observation?
|
||||
|
||||
You-must-be-logged-in-to-view-messages = You must be logged in to view messages
|
||||
|
||||
You-will-lose-all-existing-observations = {$count ->
|
||||
[one] You will lose all existing observations. Would you like to discard 1 observation?
|
||||
*[other] You will lose all existing observations. Would you like to discard {$count} observations?
|
||||
}
|
||||
|
||||
You will lose all existing observations. Would you like to discard # observations?
|
||||
|
||||
# Identification category
|
||||
Category-leading = Leading
|
||||
# Identification category
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
},
|
||||
"All": "All",
|
||||
"All-observations": "All observations",
|
||||
"all-rights-reserved": {
|
||||
"comment": "license code",
|
||||
"val": "all rights reserved"
|
||||
},
|
||||
"Amphibians": "Amphibians",
|
||||
"Arachnids": "Arachnids",
|
||||
"Are-you-sure": "Are you sure?",
|
||||
@@ -44,6 +48,10 @@
|
||||
"comment": "Quality grade option: Casual (shortened for My Observations view)",
|
||||
"val": "C"
|
||||
},
|
||||
"camera-button-label-switch-camera": {
|
||||
"comment": "Accessible label for the camera button",
|
||||
"val": "Use the device's other camera."
|
||||
},
|
||||
"Camera-Roll": "Camera Roll",
|
||||
"Cancel": "Cancel",
|
||||
"Captive-Cultivated": "Captive/Cultivated",
|
||||
@@ -77,6 +85,8 @@
|
||||
"Delete-comment": "Delete comment",
|
||||
"DELETE-X-OBSERVATIONS": "DELETE { $count ->\n [one] 1 OBSERVATION\n *[other] { $count } OBSERVATIONS\n}",
|
||||
"Description-Tags": "Description/Tags",
|
||||
"Discard-Changes": "Discard Changes",
|
||||
"Discard-X-Observations": "{ $count ->\n [one] Discard Observation\n *[other] Discard Observations\n}",
|
||||
"Display": "Display",
|
||||
"Display-Name": "Display Name",
|
||||
"Do-not-collect-stability-and-usage-data-using-third-party-services": "Do not collect stability and usage data using third-party services",
|
||||
@@ -98,6 +108,14 @@
|
||||
"Filters": "Filters",
|
||||
"Finish": "Finish",
|
||||
"Fish": "Fish",
|
||||
"flash-button-label-flash": {
|
||||
"comment": "Accessible label for the flash button, when flas is turned on",
|
||||
"val": "The flash is turned on. Press here to disable it."
|
||||
},
|
||||
"flash-button-label-flash-off": {
|
||||
"comment": "Accessible label for the flash button, when flas is turned off",
|
||||
"val": "The flash is turned off. Press here to enable it."
|
||||
},
|
||||
"Following": "Following",
|
||||
"Forgot-Password": {
|
||||
"comment": "Forgot password link",
|
||||
@@ -187,6 +205,8 @@
|
||||
"Names": "Names",
|
||||
"Native": "Native",
|
||||
"Navigate-to-login-screen": "Navigate to login screen",
|
||||
"Navigate-to-observation-details": "Navigate to observation details screen",
|
||||
"Navigate-to-taxon-details": "Navigate to taxon details",
|
||||
"Nearby": {
|
||||
"comment": "Header for nearby projects",
|
||||
"val": "Nearby"
|
||||
@@ -209,6 +229,10 @@
|
||||
"No-comments-or-ids-to-display": "No comments or ids to display",
|
||||
"No-Location": "No Location",
|
||||
"No-photos-found": "No photos found. If this is your first time opening the app and giving permissions, try restarting the app.",
|
||||
"no-rights-reserved": {
|
||||
"comment": "license code",
|
||||
"val": "no rights reserved"
|
||||
},
|
||||
"Notes": {
|
||||
"comment": "Header for observation description on observation detail",
|
||||
"val": "Notes"
|
||||
@@ -216,6 +240,7 @@
|
||||
"Notifications": "Notifications",
|
||||
"Obscured": "Obscured",
|
||||
"Observation": "Observation",
|
||||
"Observation-Attribution": "Observation © { $attribution } · { $licenseCode }",
|
||||
"Observations": "Observations",
|
||||
"Open": "Open",
|
||||
"Organism-is-wild": {
|
||||
@@ -315,6 +340,7 @@
|
||||
"comment": "Quality grade option: Research Grade (shortened for My Observations view)",
|
||||
"val": "RG"
|
||||
},
|
||||
"Save": "Save",
|
||||
"Search-for-a-location": "Search for a location",
|
||||
"Search-for-a-project": "Search for a project",
|
||||
"Search-for-a-taxon": "Search for a taxon",
|
||||
@@ -330,6 +356,10 @@
|
||||
"comment": "Header for a section showing taxa similar to a single taxon",
|
||||
"val": "SIMILAR SPECIES"
|
||||
},
|
||||
"some-rights-reserved": {
|
||||
"comment": "license code",
|
||||
"val": "some rights reserved"
|
||||
},
|
||||
"Sort-By": "Sort By",
|
||||
"Sort-by": "Sort by",
|
||||
"Species": "Species",
|
||||
@@ -403,6 +433,10 @@
|
||||
"comment": "Message shown when a permission is required to use a part of the app\n(e.g. permission to access the camera) but the user denied the permission.",
|
||||
"val": "You denied iNaturalist permission to do that"
|
||||
},
|
||||
"You-have-unsaved-changes": "You have unsaved changes. Would you like to save this observation?",
|
||||
"You-must-be-logged-in-to-view-messages": "You must be logged in to view messages",
|
||||
"You-will-lose-all-existing-observations": "{ $count ->\n [one] You will lose all existing observations. Would you like to discard 1 observation?\n *[other] You will lose all existing observations. Would you like to discard { $count } observations?\n}",
|
||||
"You will lose all existing observations. Would you like to discard # observations?": "",
|
||||
"Category-leading": {
|
||||
"comment": "Identification category",
|
||||
"val": "Leading"
|
||||
|
||||
@@ -33,6 +33,9 @@ All = All
|
||||
|
||||
All-observations = All observations
|
||||
|
||||
# license code
|
||||
all-rights-reserved = all rights reserved
|
||||
|
||||
Amphibians = Amphibians
|
||||
|
||||
Arachnids = Arachnids
|
||||
@@ -60,6 +63,9 @@ Blocked-Users = Blocked Users
|
||||
# Quality grade option: Casual (shortened for My Observations view)
|
||||
C = C
|
||||
|
||||
# Accessible label for the camera button
|
||||
camera-button-label-switch-camera = Use the device's other camera.
|
||||
|
||||
Camera-Roll = Camera Roll
|
||||
|
||||
Cancel = Cancel
|
||||
@@ -114,6 +120,13 @@ DELETE-X-OBSERVATIONS = DELETE {$count ->
|
||||
|
||||
Description-Tags = Description/Tags
|
||||
|
||||
Discard-Changes = Discard Changes
|
||||
|
||||
Discard-X-Observations = {$count ->
|
||||
[one] Discard Observation
|
||||
*[other] Discard Observations
|
||||
}
|
||||
|
||||
Display = Display
|
||||
|
||||
Display-Name = Display Name
|
||||
@@ -146,6 +159,12 @@ Finish = Finish
|
||||
|
||||
Fish = Fish
|
||||
|
||||
# Accessible label for the flash button, when flas is turned on
|
||||
flash-button-label-flash = The flash is turned on. Press here to disable it.
|
||||
|
||||
# Accessible label for the flash button, when flas is turned off
|
||||
flash-button-label-flash-off = The flash is turned off. Press here to enable it.
|
||||
|
||||
Following = Following
|
||||
|
||||
# Forgot password link
|
||||
@@ -276,6 +295,10 @@ Native = Native
|
||||
|
||||
Navigate-to-login-screen = Navigate to login screen
|
||||
|
||||
Navigate-to-observation-details = Navigate to observation details screen
|
||||
|
||||
Navigate-to-taxon-details = Navigate to taxon details
|
||||
|
||||
# Header for nearby projects
|
||||
Nearby = Nearby
|
||||
|
||||
@@ -300,6 +323,9 @@ No-Location = No Location
|
||||
|
||||
No-photos-found = No photos found. If this is your first time opening the app and giving permissions, try restarting the app.
|
||||
|
||||
# license code
|
||||
no-rights-reserved = no rights reserved
|
||||
|
||||
# Header for observation description on observation detail
|
||||
Notes = Notes
|
||||
|
||||
@@ -309,6 +335,8 @@ Obscured = Obscured
|
||||
|
||||
Observation = Observation
|
||||
|
||||
Observation-Attribution = Observation © {$attribution} · {$licenseCode}
|
||||
|
||||
Observations = Observations
|
||||
|
||||
Open = Open
|
||||
@@ -467,6 +495,8 @@ Revoke = Revoke
|
||||
# Quality grade option: Research Grade (shortened for My Observations view)
|
||||
RG = RG
|
||||
|
||||
Save = Save
|
||||
|
||||
Search-for-a-location = Search for a location
|
||||
|
||||
Search-for-a-project = Search for a project
|
||||
@@ -492,6 +522,9 @@ Sign-Up = Sign Up
|
||||
# Header for a section showing taxa similar to a single taxon
|
||||
SIMILAR-SPECIES-header = SIMILAR SPECIES
|
||||
|
||||
# license code
|
||||
some-rights-reserved = some rights reserved
|
||||
|
||||
Sort-By = Sort By
|
||||
|
||||
Sort-by = Sort by
|
||||
@@ -608,6 +641,17 @@ Record-a-sound = Record a sound
|
||||
# (e.g. permission to access the camera) but the user denied the permission.
|
||||
You-denied-iNaturalist-permission-to-do-that = You denied iNaturalist permission to do that
|
||||
|
||||
You-have-unsaved-changes = You have unsaved changes. Would you like to save this observation?
|
||||
|
||||
You-must-be-logged-in-to-view-messages = You must be logged in to view messages
|
||||
|
||||
You-will-lose-all-existing-observations = {$count ->
|
||||
[one] You will lose all existing observations. Would you like to discard 1 observation?
|
||||
*[other] You will lose all existing observations. Would you like to discard {$count} observations?
|
||||
}
|
||||
|
||||
You will lose all existing observations. Would you like to discard # observations?
|
||||
|
||||
# Identification category
|
||||
Category-leading = Leading
|
||||
# Identification category
|
||||
|
||||
@@ -9,7 +9,6 @@ import AddID from "components/ObsEdit/AddID";
|
||||
import ObsEdit from "components/ObsEdit/ObsEdit";
|
||||
import ObsList from "components/Observations/ObsList";
|
||||
import GroupPhotos from "components/PhotoImporter/GroupPhotos";
|
||||
import PhotoAlbumPicker from "components/PhotoImporter/PhotoAlbumPicker";
|
||||
import PhotoGallery from "components/PhotoImporter/PhotoGallery";
|
||||
import Mortal from "components/SharedComponents/Mortal";
|
||||
import PermissionGate from "components/SharedComponents/PermissionGate";
|
||||
@@ -69,8 +68,6 @@ const ObsEditWithPermission = () => (
|
||||
</Mortal>
|
||||
);
|
||||
|
||||
const photoGalleryHeaderTitle = ( ) => <PhotoAlbumPicker />;
|
||||
|
||||
const MainStackNavigation = ( ): React.Node => (
|
||||
<Mortal>
|
||||
<Stack.Navigator screenOptions={showHeader}>
|
||||
@@ -90,9 +87,7 @@ const MainStackNavigation = ( ): React.Node => (
|
||||
<Stack.Screen
|
||||
name="PhotoGallery"
|
||||
component={PhotoGalleryWithPermission}
|
||||
options={{
|
||||
headerTitle: photoGalleryHeaderTitle
|
||||
}}
|
||||
options={blankHeaderTitle}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="GroupPhotos"
|
||||
@@ -111,7 +106,10 @@ const MainStackNavigation = ( ): React.Node => (
|
||||
<Stack.Screen
|
||||
name="ObsEdit"
|
||||
component={ObsEditWithPermission}
|
||||
options={blankHeaderTitle}
|
||||
options={{
|
||||
...blankHeaderTitle,
|
||||
headerBackVisible: false
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AddID"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// @flow
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { searchObservations } from "api/observations";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import Observation from "realmModels/Observation";
|
||||
import ObservationPhoto from "realmModels/ObservationPhoto";
|
||||
import Photo from "realmModels/Photo";
|
||||
import { formatDateAndTime } from "sharedHelpers/dateAndTime";
|
||||
import { formatDateStringFromTimestamp } from "sharedHelpers/dateAndTime";
|
||||
import fetchPlaceName from "sharedHelpers/fetchPlaceName";
|
||||
import { formatExifDateAsString, parseExif } from "sharedHelpers/parseExif";
|
||||
import useApiToken from "sharedHooks/useApiToken";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
import { ObsEditContext, RealmContext } from "./contexts";
|
||||
|
||||
@@ -21,12 +24,15 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const realm = useRealm( );
|
||||
const apiToken = useApiToken( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const [currentObservationIndex, setCurrentObservationIndex] = useState( 0 );
|
||||
const [observations, setObservations] = useState( [] );
|
||||
const [cameraPreviewUris, setCameraPreviewUris] = useState( [] );
|
||||
const [galleryUris, setGalleryUris] = useState( [] );
|
||||
const [evidenceToAdd, setEvidenceToAdd] = useState( [] );
|
||||
const [album, setAlbum] = useState( null );
|
||||
const [loading, setLoading] = useState( );
|
||||
const [unsavedChanges, setUnsavedChanges] = useState( false );
|
||||
|
||||
const resetObsEditContext = useCallback( ( ) => {
|
||||
setObservations( [] );
|
||||
@@ -60,18 +66,26 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
), [] );
|
||||
|
||||
const createObservationFromGalleryPhoto = useCallback( async photo => {
|
||||
const latitude = photo?.location?.latitude || null;
|
||||
const longitude = photo?.location?.longitude || null;
|
||||
const originalPhotoUri = photo?.image?.uri;
|
||||
const firstPhotoExif = await parseExif( originalPhotoUri );
|
||||
const exifDate = formatExifDateAsString( firstPhotoExif.date );
|
||||
|
||||
const observedOnDate = exifDate || formatDateStringFromTimestamp( photo.timestamp );
|
||||
const latitude = firstPhotoExif.latitude || photo?.location?.latitude;
|
||||
const longitude = firstPhotoExif.longitude || photo?.location?.longitude;
|
||||
const placeGuess = await fetchPlaceName( latitude, longitude );
|
||||
// create a new observation using the data in the first grouped photo
|
||||
// TODO: figure out if we want to loop through observations, looking for one
|
||||
// with lat/lng, if the first photo lat/lng is blank
|
||||
|
||||
const newObservation = {
|
||||
latitude,
|
||||
longitude,
|
||||
time_observed_at: formatDateAndTime( photo.timestamp ),
|
||||
place_guess: placeGuess
|
||||
place_guess: placeGuess,
|
||||
observed_on_string: observedOnDate
|
||||
};
|
||||
|
||||
if ( firstPhotoExif.positional_accuracy ) {
|
||||
// $FlowIgnore
|
||||
newObservation.positional_accuracy = firstPhotoExif.positional_accuracy;
|
||||
}
|
||||
return Observation.new( newObservation );
|
||||
}, [] );
|
||||
|
||||
@@ -101,6 +115,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
setObservations( [updatedObs] );
|
||||
// clear additional evidence
|
||||
setEvidenceToAdd( [] );
|
||||
setUnsavedChanges( true );
|
||||
}, [currentObservation] );
|
||||
|
||||
const addGalleryPhotosToCurrentObservation = useCallback( async photos => {
|
||||
@@ -130,13 +145,13 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
if ( index === currentObservationIndex ) {
|
||||
return {
|
||||
...( observation.toJSON ? observation.toJSON( ) : observation ),
|
||||
// $FlowFixMe
|
||||
[key]: value
|
||||
};
|
||||
}
|
||||
return observation;
|
||||
} );
|
||||
setObservations( updatedObservations );
|
||||
setUnsavedChanges( true );
|
||||
};
|
||||
|
||||
const updateObservationKeys = keysAndValues => {
|
||||
@@ -151,6 +166,7 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
return observation;
|
||||
} );
|
||||
setObservations( updatedObservations );
|
||||
setUnsavedChanges( true );
|
||||
};
|
||||
|
||||
const setNextScreen = ( ) => {
|
||||
@@ -179,24 +195,22 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
};
|
||||
|
||||
const saveObservation = async ( ) => {
|
||||
const localObs = await Observation.saveLocalObservationForUpload( currentObservation, realm );
|
||||
if ( localObs ) {
|
||||
setNextScreen( );
|
||||
}
|
||||
};
|
||||
|
||||
const saveAndUploadObservation = async ( ) => {
|
||||
const localObs = await Observation.saveLocalObservationForUpload( currentObservation, realm );
|
||||
if ( !realm ) {
|
||||
throw new Error( "Gack, tried to save an observation without realm!" );
|
||||
}
|
||||
return Observation.saveLocalObservationForUpload( currentObservation, realm );
|
||||
};
|
||||
|
||||
const uploadObservation = async observation => {
|
||||
if ( !apiToken ) {
|
||||
throw new Error( "Gack, tried to save an observation without API token!" );
|
||||
}
|
||||
Observation.uploadObservation( localObs, apiToken, realm );
|
||||
if ( localObs ) {
|
||||
setNextScreen( );
|
||||
throw new Error( "Gack, tried to upload an observation without API token!" );
|
||||
}
|
||||
return Observation.uploadObservation( observation, apiToken, realm );
|
||||
};
|
||||
|
||||
const saveAndUploadObservation = async ( ) => {
|
||||
const savedObservation = await saveObservation( );
|
||||
return uploadObservation( savedObservation );
|
||||
};
|
||||
|
||||
const removePhotoFromList = ( list, photo ) => {
|
||||
@@ -223,6 +237,32 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
await Photo.deletePhoto( realm, photoUriToDelete );
|
||||
};
|
||||
|
||||
const uploadLocalObservationsToServer = ( ) => {
|
||||
const unsyncedObservations = Observation.filterUnsyncedObservations( realm );
|
||||
unsyncedObservations.forEach( async observation => {
|
||||
await Observation.uploadObservation( observation, apiToken, realm );
|
||||
} );
|
||||
};
|
||||
|
||||
const downloadRemoteObservationsFromServer = async ( ) => {
|
||||
const params = {
|
||||
user_id: currentUser?.id,
|
||||
per_page: 50,
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
const results = await searchObservations( params, { api_token: apiToken } );
|
||||
|
||||
Observation.upsertRemoteObservations( results, realm );
|
||||
};
|
||||
|
||||
const syncObservations = async ( ) => {
|
||||
// TODO: GET observation/deletions once this is enabled in API v2
|
||||
setLoading( true );
|
||||
await uploadLocalObservationsToServer( );
|
||||
await downloadRemoteObservationsFromServer( );
|
||||
setLoading( false );
|
||||
};
|
||||
|
||||
return {
|
||||
createObservationNoEvidence,
|
||||
addObservations,
|
||||
@@ -252,7 +292,13 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
deleteLocalObservation,
|
||||
album,
|
||||
setAlbum,
|
||||
deletePhotoFromObservation
|
||||
deletePhotoFromObservation,
|
||||
uploadObservation,
|
||||
setNextScreen,
|
||||
loading,
|
||||
setLoading,
|
||||
unsavedChanges,
|
||||
syncObservations
|
||||
};
|
||||
}, [
|
||||
currentObservation,
|
||||
@@ -273,7 +319,11 @@ const ObsEditProvider = ( { children }: Props ): Node => {
|
||||
navigation,
|
||||
realm,
|
||||
album,
|
||||
setAlbum
|
||||
setAlbum,
|
||||
loading,
|
||||
setLoading,
|
||||
unsavedChanges,
|
||||
currentUser?.id
|
||||
] );
|
||||
|
||||
return (
|
||||
|
||||
27
src/realmModels/Application.js
Normal file
27
src/realmModels/Application.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Realm } from "@realm/react";
|
||||
|
||||
class Application extends Realm.Object {
|
||||
static APPLICATION_FIELDS = {
|
||||
name: true
|
||||
};
|
||||
|
||||
static mapApiToRealm( application ) {
|
||||
return application;
|
||||
}
|
||||
|
||||
static schema = {
|
||||
name: "Application",
|
||||
properties: {
|
||||
name: "string?",
|
||||
// this creates an inverse relationship so applications
|
||||
// automatically keep track of which Observation they are assigned to
|
||||
assignee: {
|
||||
type: "linkingObjects",
|
||||
objectType: "Observation",
|
||||
property: "application"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Application;
|
||||
@@ -5,6 +5,7 @@ import inatjs from "inaturalistjs";
|
||||
import uuid from "react-native-uuid";
|
||||
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
|
||||
|
||||
import Application from "./Application";
|
||||
import Comment from "./Comment";
|
||||
import Identification from "./Identification";
|
||||
import ObservationPhoto from "./ObservationPhoto";
|
||||
@@ -17,6 +18,7 @@ import User from "./User";
|
||||
// https://github.com/realm/realm-js/issues/3600#issuecomment-785828614
|
||||
class Observation extends Realm.Object {
|
||||
static FIELDS = {
|
||||
application: Application.APPLICATION_FIELDS,
|
||||
captive: true,
|
||||
comments: Comment.COMMENT_FIELDS,
|
||||
created_at: true,
|
||||
@@ -26,6 +28,7 @@ class Observation extends Realm.Object {
|
||||
id: true,
|
||||
identifications: Identification.ID_FIELDS,
|
||||
latitude: true,
|
||||
license_code: true,
|
||||
location: true,
|
||||
longitude: true,
|
||||
observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
|
||||
@@ -42,9 +45,8 @@ class Observation extends Realm.Object {
|
||||
captive_flag: false,
|
||||
geoprivacy: "open",
|
||||
owners_identification_from_vision: false,
|
||||
observed_on_string: createObservedOnStringForUpload( ),
|
||||
observed_on_string: obs?.observed_on_string || createObservedOnStringForUpload( ),
|
||||
quality_grade: "needs_id",
|
||||
// project_ids: [],
|
||||
uuid: uuid.v4( )
|
||||
};
|
||||
}
|
||||
@@ -56,45 +58,28 @@ class Observation extends Realm.Object {
|
||||
return observation;
|
||||
}
|
||||
|
||||
static mimicRealmMappedPropertiesSchema( obs ) {
|
||||
const createLinkedObjects = ( list, createFunction ) => {
|
||||
if ( list.length === 0 ) { return list; }
|
||||
return list.map( item => {
|
||||
if ( createFunction === Identification ) {
|
||||
// this one requires special treatment for appending taxon objects
|
||||
return createFunction.mimicRealmMappedPropertiesSchema( item );
|
||||
}
|
||||
return createFunction.mapApiToRealm( item );
|
||||
} );
|
||||
};
|
||||
|
||||
const taxon = obs.taxon ? Taxon.mimicRealmMappedPropertiesSchema( obs.taxon ) : null;
|
||||
const observationPhotos = createLinkedObjects( obs.observation_photos, ObservationPhoto );
|
||||
const comments = createLinkedObjects( obs.comments, Comment );
|
||||
const identifications = createLinkedObjects( obs.identifications, Identification );
|
||||
const user = User.mapApiToRealm( obs.user );
|
||||
|
||||
return {
|
||||
...obs,
|
||||
comments: comments || [],
|
||||
createdAt: obs.created_at,
|
||||
identifications: identifications || [],
|
||||
latitude: obs.geojson ? obs.geojson.coordinates[1] : null,
|
||||
longitude: obs.geojson ? obs.geojson.coordinates[0] : null,
|
||||
observationPhotos,
|
||||
placeGuess: obs.place_guess,
|
||||
qualityGrade: obs.quality_grade,
|
||||
taxon,
|
||||
timeObservedAt: obs.time_observed_at,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
static createLinkedObjects = ( list, createFunction, realm ) => {
|
||||
if ( list.length === 0 ) { return list; }
|
||||
return list.map( item => createFunction.mapApiToRealm( item, realm ) );
|
||||
};
|
||||
|
||||
static upsertRemoteObservations( observations, realm ) {
|
||||
if ( observations && observations.length > 0 ) {
|
||||
const obsToUpsert = observations.filter(
|
||||
obs => !Observation.isUnsyncedObservation( realm, obs )
|
||||
);
|
||||
realm.write( ( ) => {
|
||||
obsToUpsert.forEach( obs => {
|
||||
realm.create(
|
||||
"Observation",
|
||||
Observation.createOrModifyLocalObservation( obs, realm ),
|
||||
"modified"
|
||||
);
|
||||
} );
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
static createOrModifyLocalObservation( obs, realm ) {
|
||||
const existingObs = realm?.objectForPrimaryKey( "Observation", obs.uuid );
|
||||
const taxon = obs.taxon ? Taxon.mapApiToRealm( obs.taxon ) : null;
|
||||
@@ -110,10 +95,12 @@ class Observation extends Realm.Object {
|
||||
realm
|
||||
);
|
||||
const user = User.mapApiToRealm( obs.user );
|
||||
const application = Application.mapApiToRealm( obs.application );
|
||||
|
||||
const localObs = {
|
||||
...obs,
|
||||
_synced_at: new Date( ),
|
||||
application,
|
||||
comments,
|
||||
identifications,
|
||||
// obs detail on web says geojson coords are preferred over lat/long
|
||||
@@ -124,6 +111,7 @@ class Observation extends Realm.Object {
|
||||
taxon,
|
||||
user
|
||||
};
|
||||
|
||||
if ( !existingObs ) {
|
||||
localObs._created_at = new Date( localObs.created_at );
|
||||
if ( isNaN( localObs._created_at ) ) {
|
||||
@@ -238,35 +226,14 @@ class Observation extends Realm.Object {
|
||||
return unsyncedObs.length > 0;
|
||||
}
|
||||
|
||||
wasSynced( ) {
|
||||
return this._synced_at !== null;
|
||||
}
|
||||
|
||||
static updateLocalObservationsFromRemote = ( realm, results ) => {
|
||||
if ( results.length === 0 ) { return; }
|
||||
const obsToUpsert = results.filter( obs => !Observation.isUnsyncedObservation( realm, obs ) );
|
||||
realm.write( ( ) => {
|
||||
obsToUpsert.forEach( obs => {
|
||||
realm.create(
|
||||
"Observation",
|
||||
Observation.createOrModifyLocalObservation( obs, realm ),
|
||||
"modified"
|
||||
);
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
static markRecordUploaded = async ( recordUUID, type, response, realm ) => {
|
||||
const { id } = response.results[0];
|
||||
try {
|
||||
const record = realm.objectForPrimaryKey( type, recordUUID );
|
||||
realm?.write( ( ) => {
|
||||
record.id = id;
|
||||
record._synced_at = new Date( );
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.log( e, `couldn't mark ${type} uploaded in realm` );
|
||||
}
|
||||
|
||||
const record = realm.objectForPrimaryKey( type, recordUUID );
|
||||
realm?.write( ( ) => {
|
||||
record.id = id;
|
||||
record._synced_at = new Date( );
|
||||
} );
|
||||
};
|
||||
|
||||
static uploadToServer = async (
|
||||
@@ -278,8 +245,9 @@ class Observation extends Realm.Object {
|
||||
options: Object
|
||||
) => {
|
||||
const response = await createOrUpdateEvidence( apiEndpoint, params, options );
|
||||
await Observation.markRecordUploaded( evidenceUUID, type, response, realm );
|
||||
return response;
|
||||
if ( response ) {
|
||||
await Observation.markRecordUploaded( evidenceUUID, type, response, realm );
|
||||
}
|
||||
};
|
||||
|
||||
static uploadEvidence = async (
|
||||
@@ -291,13 +259,11 @@ class Observation extends Realm.Object {
|
||||
realm: any,
|
||||
options: Object
|
||||
): Promise<any> => {
|
||||
let response;
|
||||
|
||||
// only try to upload evidence which is not yet on the server
|
||||
const unsyncedEvidence = evidence.filter( item => !item.wasSynced( ) );
|
||||
|
||||
for ( let i = 0; i < unsyncedEvidence.length; i += 1 ) {
|
||||
const currentEvidence = unsyncedEvidence[i].toJSON( );
|
||||
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
|
||||
@@ -312,7 +278,7 @@ class Observation extends Realm.Object {
|
||||
currentEvidence.photo = newPhoto;
|
||||
|
||||
const params = apiSchemaMapper( observationId, currentEvidence );
|
||||
response = Observation.uploadToServer(
|
||||
return Observation.uploadToServer(
|
||||
evidenceUUID,
|
||||
type,
|
||||
params,
|
||||
@@ -320,9 +286,9 @@ class Observation extends Realm.Object {
|
||||
realm,
|
||||
options
|
||||
);
|
||||
}
|
||||
} ) );
|
||||
// eslint-disable-next-line consistent-return
|
||||
return response;
|
||||
return responses[0];
|
||||
};
|
||||
|
||||
static uploadObservation = async ( obs, apiToken, realm ) => {
|
||||
@@ -359,14 +325,14 @@ class Observation extends Realm.Object {
|
||||
}
|
||||
|
||||
await Observation.markRecordUploaded( obs.uuid, "Observation", response, realm );
|
||||
const { id } = response.results[0];
|
||||
const { uuid: obsUUID } = response.results[0];
|
||||
|
||||
if ( obs?.observationPhotos?.length > 0 ) {
|
||||
await Observation.uploadEvidence(
|
||||
obs.observationPhotos,
|
||||
"ObservationPhoto",
|
||||
ObservationPhoto.mapPhotoForUpload,
|
||||
id,
|
||||
obsUUID,
|
||||
inatjs.observation_photos.create,
|
||||
realm,
|
||||
options
|
||||
@@ -377,7 +343,7 @@ class Observation extends Realm.Object {
|
||||
obs.observationSounds,
|
||||
"ObservationSound",
|
||||
ObservationSound.mapSoundForUpload,
|
||||
id,
|
||||
obsUUID,
|
||||
inatjs.observation_sounds.create,
|
||||
realm,
|
||||
options
|
||||
@@ -397,6 +363,7 @@ class Observation extends Realm.Object {
|
||||
// datetime the observation was updated on the device (i.e. edited locally)
|
||||
_updated_at: "date?",
|
||||
uuid: "string",
|
||||
application: "Application?",
|
||||
captive_flag: "bool?",
|
||||
comments: "Comment[]",
|
||||
// timestamp of when observation was created on the server; not editable
|
||||
@@ -406,6 +373,7 @@ class Observation extends Realm.Object {
|
||||
id: "int?",
|
||||
identifications: "Identification[]",
|
||||
latitude: "double?",
|
||||
license_code: { type: "string?", mapTo: "licenseCode" },
|
||||
longitude: "double?",
|
||||
observationPhotos: "ObservationPhoto[]",
|
||||
observationSounds: "ObservationSound[]",
|
||||
@@ -424,6 +392,16 @@ class Observation extends Realm.Object {
|
||||
viewed: "bool?"
|
||||
}
|
||||
}
|
||||
|
||||
needsSync( ) {
|
||||
const obsPhotosNeedSync = this.observationPhotos
|
||||
.filter( obsPhoto => obsPhoto.needsSync( ) ).length > 0;
|
||||
return !this._synced_at || this._synced_at <= this._updated_at || obsPhotosNeedSync;
|
||||
}
|
||||
|
||||
wasSynced( ) {
|
||||
return this._synced_at !== null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Observation;
|
||||
|
||||
@@ -12,6 +12,10 @@ class ObservationPhoto extends Realm.Object {
|
||||
uuid: true
|
||||
};
|
||||
|
||||
needsSync( ) {
|
||||
return !this._synced_at || this._synced_at <= this._updated_at;
|
||||
}
|
||||
|
||||
wasSynced( ) {
|
||||
return this._synced_at !== null;
|
||||
}
|
||||
@@ -31,9 +35,9 @@ class ObservationPhoto extends Realm.Object {
|
||||
return localObsPhoto;
|
||||
}
|
||||
|
||||
static mapPhotoForUpload( id, observationPhoto ) {
|
||||
static mapPhotoForUpload( observtionID, observationPhoto ) {
|
||||
return {
|
||||
"observation_photo[observation_id]": id,
|
||||
"observation_photo[observation_id]": observtionID,
|
||||
"observation_photo[uuid]": observationPhoto.uuid,
|
||||
file: new FileUpload( {
|
||||
uri: observationPhoto.photo.localFilePath,
|
||||
|
||||
@@ -24,7 +24,7 @@ class Photo extends Realm.Object {
|
||||
return localPhoto;
|
||||
}
|
||||
|
||||
static async resizeImageForUpload( pathOrUri ) {
|
||||
static async resizeImageForUpload( pathOrUri, options = {} ) {
|
||||
const width = 2048;
|
||||
const { photoUploadPath } = Photo;
|
||||
await RNFS.mkdir( photoUploadPath );
|
||||
@@ -59,7 +59,7 @@ class Photo extends Realm.Object {
|
||||
width, // height
|
||||
"JPEG", // compressFormat
|
||||
100, // quality
|
||||
0, // rotation
|
||||
options.rotation || 0, // rotation
|
||||
photoUploadPath,
|
||||
true, // keep metadata
|
||||
{
|
||||
@@ -70,8 +70,8 @@ class Photo extends Realm.Object {
|
||||
return uri;
|
||||
}
|
||||
|
||||
static async new( uri ) {
|
||||
const localFilePath = await Photo.resizeImageForUpload( uri );
|
||||
static async new( uri, resizeOptions = {} ) {
|
||||
const localFilePath = await Photo.resizeImageForUpload( uri, resizeOptions );
|
||||
|
||||
return {
|
||||
_created_at: new Date( ),
|
||||
|
||||
@@ -7,7 +7,7 @@ class User extends Realm.Object {
|
||||
login: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
observation_count: true
|
||||
observations_count: true
|
||||
};
|
||||
|
||||
static mapApiToRealm( user ) {
|
||||
@@ -30,7 +30,7 @@ class User extends Realm.Object {
|
||||
name: "string?",
|
||||
signedIn: "bool?",
|
||||
locale: "string?",
|
||||
observation_count: "int?"
|
||||
observations_count: "int?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Application from "./Application";
|
||||
import Comment from "./Comment";
|
||||
import Identification from "./Identification";
|
||||
import Observation from "./Observation";
|
||||
@@ -9,6 +10,7 @@ import User from "./User";
|
||||
|
||||
export default {
|
||||
schema: [
|
||||
Application,
|
||||
Comment,
|
||||
Identification,
|
||||
Observation,
|
||||
@@ -18,7 +20,7 @@ export default {
|
||||
Taxon,
|
||||
User
|
||||
],
|
||||
schemaVersion: 26,
|
||||
schemaVersion: 28,
|
||||
path: "db.realm",
|
||||
migration: ( oldRealm, newRealm ) => {
|
||||
if ( oldRealm.schemaVersion < 21 ) {
|
||||
|
||||
@@ -3,23 +3,27 @@ import {
|
||||
format, formatDistanceToNow, formatISO, fromUnixTime, getUnixTime, getYear, parseISO
|
||||
} from "date-fns";
|
||||
|
||||
// two options for observed_on_string in uploader are:
|
||||
// 2020-03-01 00:00 or 2021-03-24T14:40:25
|
||||
// this is using the second format
|
||||
// https://github.com/inaturalist/inaturalist/blob/b12f16099fc8ad0c0961900d644507f6952bec66/spec/controllers/observation_controller_api_spec.rb#L161
|
||||
const formatDateAndTime = timestamp => {
|
||||
const date = fromUnixTime( timestamp );
|
||||
const formatISONoTimezone = date => {
|
||||
const formattedISODate = formatISO( date );
|
||||
// Always take the first part of the time/date string,
|
||||
// without any extra timezone, etc (just "2022-12-31T23:59:59")
|
||||
return formattedISODate.substring( 0, 19 );
|
||||
};
|
||||
|
||||
const createObservedOnStringForUpload = date => formatDateAndTime(
|
||||
// two options for observed_on_string in uploader are:
|
||||
// 2020-03-01 00:00 or 2021-03-24T14:40:25
|
||||
// this is using the second format
|
||||
// https://github.com/inaturalist/inaturalist/blob/b12f16099fc8ad0c0961900d644507f6952bec66/spec/controllers/observation_controller_api_spec.rb#L161
|
||||
const formatDateStringFromTimestamp = timestamp => {
|
||||
const date = fromUnixTime( timestamp );
|
||||
return formatISONoTimezone( date );
|
||||
};
|
||||
|
||||
const createObservedOnStringForUpload = date => formatDateStringFromTimestamp(
|
||||
getUnixTime( date || new Date( ) )
|
||||
);
|
||||
|
||||
const displayDateTimeObsEdit = date => format( new Date( date ), "PPpp" );
|
||||
const displayDateTimeObsEdit = date => date && format( new Date( date ), "PPpp" );
|
||||
|
||||
const timeAgo = pastTime => formatDistanceToNow( new Date( pastTime ) );
|
||||
|
||||
@@ -66,8 +70,9 @@ const formatIdDate = ( date, t ) => {
|
||||
export {
|
||||
createObservedOnStringForUpload,
|
||||
displayDateTimeObsEdit,
|
||||
formatDateAndTime,
|
||||
formatDateStringFromTimestamp,
|
||||
formatIdDate,
|
||||
formatISONoTimezone,
|
||||
formatObsListTime,
|
||||
timeAgo
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const fetchPlaceName = async ( lat: number, lng: number ): Promise<?string> => {
|
||||
if ( results.length === 0 ) { return null; }
|
||||
return setPlaceName( results );
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't fetch geocoded position with coordinates: ", lat, lng );
|
||||
console.warn( e, "couldn't fetch geocoded position with coordinates: ", lat, lng );
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const requestLocationPermissions = async ( ): Promise<?string> => {
|
||||
const permission = await request( PERMISSIONS.IOS.LOCATION_WHEN_IN_USE );
|
||||
return permission;
|
||||
} catch ( e ) {
|
||||
console.log( e, ": error requesting iOS permissions" );
|
||||
console.warn( e, ": error requesting iOS permissions" );
|
||||
}
|
||||
}
|
||||
if ( Platform.OS === "android" ) {
|
||||
@@ -21,7 +21,7 @@ const requestLocationPermissions = async ( ): Promise<?string> => {
|
||||
const permission = await request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION );
|
||||
return permission;
|
||||
} catch ( e ) {
|
||||
console.log( e, ": error requesting android permissions" );
|
||||
console.warn( e, ": error requesting android permissions" );
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -59,7 +59,7 @@ const fetchUserLocation = async ( ): Promise<?UserLocation> => {
|
||||
positional_accuracy: coords.accuracy
|
||||
};
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't get latLng" );
|
||||
console.warn( e, "couldn't get latLng" );
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
47
src/sharedHelpers/parseExif.js
Normal file
47
src/sharedHelpers/parseExif.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// @flow
|
||||
|
||||
import { utcToZonedTime } from "date-fns-tz";
|
||||
import { readExif } from "react-native-exif-reader";
|
||||
import * as RNLocalize from "react-native-localize";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime";
|
||||
|
||||
class UsePhotoExifDateFormatError extends Error {}
|
||||
|
||||
// https://wbinnssmith.com/blog/subclassing-error-in-modern-javascript/
|
||||
Object.defineProperty( UsePhotoExifDateFormatError.prototype, "name", {
|
||||
value: "UsePhotoExifDateFormatError"
|
||||
} );
|
||||
|
||||
// Parses EXIF date time into a date object
|
||||
export const parseExifDateToLocalTimezone = ( datetime: string ): ?Date => {
|
||||
if ( !datetime ) return null;
|
||||
|
||||
const isoDate = `${datetime}Z`;
|
||||
|
||||
// Use local timezone from device
|
||||
const timeZone = RNLocalize.getTimeZone( );
|
||||
const zonedDate = utcToZonedTime( isoDate, timeZone );
|
||||
|
||||
if ( !zonedDate || zonedDate.toString( ).match( /invalid/i ) ) {
|
||||
throw new UsePhotoExifDateFormatError( "Date was not formatted correctly" );
|
||||
}
|
||||
|
||||
return zonedDate;
|
||||
};
|
||||
|
||||
// Parses EXIF date time into a date object
|
||||
export const parseExif = async ( photoUri: ?string ): Promise<Object> => {
|
||||
try {
|
||||
return await readExif( photoUri );
|
||||
} catch ( e ) {
|
||||
console.error( e, "Couldn't parse EXIF" );
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatExifDateAsString = ( datetime: string ): string => {
|
||||
const zonedDate = parseExifDateToLocalTimezone( datetime );
|
||||
// this returns a string, in the same format as photos which fall back to the
|
||||
// photo timestamp instead of exif data
|
||||
return formatISONoTimezone( zonedDate );
|
||||
};
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import useCurrentUser from "./useCurrentUser";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
const useApiToken = ( ): string | null => {
|
||||
const [apiToken, setApiToken] = useState( null );
|
||||
|
||||
@@ -6,19 +6,20 @@ import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
|
||||
// Should work like React Query's useMutation except it calls the queryFunction
|
||||
// with an object that includes the JWT
|
||||
const useAuthenticatedMutation = (
|
||||
queryFunction: Function,
|
||||
mutationOptions: Object,
|
||||
queryOptions: Object = {}
|
||||
): any => useMutation( async id => {
|
||||
mutationFunction: Function,
|
||||
mutationOptions: Object = {}
|
||||
): any => useMutation( {
|
||||
mutationFn: async id => {
|
||||
// Note, getJWTToken() takes care of fetching a new token if the existing
|
||||
// one is expired. We *could* store the token in state with useState if
|
||||
// fetching from RNSInfo becomes a performance issue
|
||||
const apiToken = await getJWTToken( );
|
||||
const options = {
|
||||
...queryOptions,
|
||||
api_token: apiToken
|
||||
};
|
||||
return queryFunction( id, options );
|
||||
}, mutationOptions );
|
||||
const apiToken = await getJWTToken( );
|
||||
const options = {
|
||||
api_token: apiToken
|
||||
};
|
||||
return mutationFunction( id, options );
|
||||
},
|
||||
...mutationOptions
|
||||
} );
|
||||
|
||||
export default useAuthenticatedMutation;
|
||||
|
||||
@@ -8,18 +8,20 @@ import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
|
||||
const useAuthenticatedQuery = (
|
||||
queryKey: Array<mixed>,
|
||||
queryFunction: Function,
|
||||
queryOptions: Object = {},
|
||||
useQueryOptions: Object = {}
|
||||
): any => useQuery( [queryKey], async ( ) => {
|
||||
// Note, getJWTToken() takes care of fetching a new token if the existing
|
||||
// one is expired. We *could* store the token in state with useState if
|
||||
// fetching from RNSInfo becomes a performance issue
|
||||
const apiToken = await getJWTToken( );
|
||||
const options = {
|
||||
...queryOptions,
|
||||
api_token: apiToken
|
||||
};
|
||||
return queryFunction( options );
|
||||
}, useQueryOptions );
|
||||
queryOptions: Object = {}
|
||||
): any => useQuery( {
|
||||
queryKey,
|
||||
queryFn: async ( ) => {
|
||||
// Note, getJWTToken() takes care of fetching a new token if the existing
|
||||
// one is expired. We *could* store the token in state with useState if
|
||||
// fetching from RNSInfo becomes a performance issue
|
||||
const apiToken = await getJWTToken( );
|
||||
const options = {
|
||||
api_token: apiToken
|
||||
};
|
||||
return queryFunction( options );
|
||||
},
|
||||
...queryOptions
|
||||
} );
|
||||
|
||||
export default useAuthenticatedQuery;
|
||||
|
||||
@@ -24,7 +24,7 @@ const useCoords = ( location: string ): Object => {
|
||||
longitude: position.lng
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.log( e, "couldn't fetch coords by location name" );
|
||||
console.warn( e, "couldn't fetch coords by location name" );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
// @flow
|
||||
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const useCurrentUser = ( ) => {
|
||||
const [currentUser, setCurrentUser] = useState( null );
|
||||
const useCurrentUser = ( ): ?Object => {
|
||||
const realm = useRealm( );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const signedInUsers = realm.objects( "User" ).filtered( "signedIn == true" );
|
||||
if ( signedInUsers.length > 0 ) {
|
||||
setCurrentUser( signedInUsers[0] );
|
||||
} else {
|
||||
setCurrentUser( null );
|
||||
}
|
||||
}, [realm] );
|
||||
|
||||
return currentUser;
|
||||
return realm.objects( "User" ).filtered( "signedIn == true" )[0];
|
||||
};
|
||||
|
||||
export default useCurrentUser;
|
||||
|
||||
@@ -35,7 +35,7 @@ const useLocalObservations = ( ): Object => {
|
||||
const unsyncedObs = Observation.filterUnsyncedObservations( realm );
|
||||
setUnuploadedObsList( Array.from( unsyncedObs ) );
|
||||
|
||||
if ( allObsToUpload.length === 0 ) {
|
||||
if ( allObsToUpload.length < unsyncedObs.length ) {
|
||||
setAllObsToUpload( Array.from( unsyncedObs ) );
|
||||
}
|
||||
} );
|
||||
|
||||
@@ -45,7 +45,7 @@ const useLocationName = ( latitude: ?number, longitude: ?number ): ?string => {
|
||||
if ( results.length === 0 || !isCurrent ) { return; }
|
||||
setLocation( setPlaceName( results ) );
|
||||
} catch ( e ) {
|
||||
console.log(
|
||||
console.warn(
|
||||
e,
|
||||
"couldn't fetch geocoded position with coordinates: ",
|
||||
latitude,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { getUsername } from "components/LoginSignUp/AuthenticationService";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const useLoggedIn = ( ): ?boolean => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState( null );
|
||||
|
||||
useEffect( ( ) => {
|
||||
let isCurrent = true;
|
||||
const fetchLoggedInUser = async ( ) => {
|
||||
try {
|
||||
const currentUserLogin = await getUsername( );
|
||||
if ( !isCurrent ) { return; }
|
||||
if ( currentUserLogin ) {
|
||||
setIsLoggedIn( true );
|
||||
} else {
|
||||
setIsLoggedIn( false );
|
||||
}
|
||||
} catch ( e ) {
|
||||
if ( !isCurrent ) { return; }
|
||||
console.log( "Couldn't check whether user logged in:", e.message );
|
||||
}
|
||||
};
|
||||
|
||||
fetchLoggedInUser( );
|
||||
return ( ) => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [] );
|
||||
|
||||
return isLoggedIn;
|
||||
};
|
||||
|
||||
export default useLoggedIn;
|
||||
@@ -1,52 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import { parse } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { readExif } from "react-native-exif-reader";
|
||||
|
||||
class UsePhotoExifDateFormatError extends Error {}
|
||||
|
||||
// https://wbinnssmith.com/blog/subclassing-error-in-modern-javascript/
|
||||
Object.defineProperty( UsePhotoExifDateFormatError.prototype, "name", {
|
||||
value: "UsePhotoExifDateFormatError"
|
||||
} );
|
||||
|
||||
// Parses EXIF date time into a date object
|
||||
export const parseExifDateTime = ( datetime: string ): ?Date => {
|
||||
if ( !datetime ) return null;
|
||||
|
||||
// Assume local timezone
|
||||
const parsedDate = parse( datetime, "yyyy-MM-dd'T'HH:mm:ss.SSS", new Date() );
|
||||
if ( !parsedDate || parsedDate.toString( ).match( /invalid/i ) ) {
|
||||
throw new UsePhotoExifDateFormatError( "Date was not formatted correctly" );
|
||||
}
|
||||
return parsedDate;
|
||||
};
|
||||
|
||||
export const usePhotoExif = ( photoUri: ?string ): Object => {
|
||||
const [exif, setExif] = useState( null );
|
||||
|
||||
useEffect( ( ) => {
|
||||
let isCurrent = true;
|
||||
|
||||
const parseExif = async ( ): Promise<Object> => {
|
||||
try {
|
||||
const rawExif = await readExif( photoUri );
|
||||
|
||||
if ( !isCurrent ) { return; }
|
||||
setExif( rawExif );
|
||||
} catch ( e ) {
|
||||
console.error( e, "Couldn't parse EXIF" );
|
||||
}
|
||||
};
|
||||
|
||||
if ( photoUri ) {
|
||||
parseExif();
|
||||
}
|
||||
return ( ) => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [photoUri] );
|
||||
|
||||
return exif;
|
||||
};
|
||||
@@ -10,35 +10,21 @@ const { useRealm } = RealmContext;
|
||||
const useUploadObservations = ( allObsToUpload: Array<Object> ): Object => {
|
||||
const [cancelUpload, setCancelUpload] = useState( false );
|
||||
const [currentUploadIndex, setCurrentUploadIndex] = useState( 0 );
|
||||
const [status, setStatus] = useState( null );
|
||||
const realm = useRealm( );
|
||||
const apiToken = useApiToken( );
|
||||
|
||||
const handleClosePress = useCallback( ( ) => {
|
||||
setCancelUpload( true );
|
||||
setStatus( null );
|
||||
}, [] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const upload = async obs => {
|
||||
if ( !apiToken ) return;
|
||||
const response = await Observation.uploadObservation( obs, apiToken, realm );
|
||||
if ( response.results ) { return; }
|
||||
if ( response.status !== 200 ) {
|
||||
const error = JSON.parse( response );
|
||||
// guard against 500 errors / server downtime errors
|
||||
if ( error?.url?.includes( "observation_photos" ) ) {
|
||||
setStatus( "photoFailure" );
|
||||
} else {
|
||||
setStatus( "failure" );
|
||||
}
|
||||
}
|
||||
};
|
||||
if ( currentUploadIndex < allObsToUpload.length - 1 ) {
|
||||
await Observation.uploadObservation( obs, apiToken, realm );
|
||||
setCurrentUploadIndex( currentUploadIndex + 1 );
|
||||
}
|
||||
};
|
||||
|
||||
if ( !cancelUpload ) {
|
||||
if ( !cancelUpload && allObsToUpload[currentUploadIndex] ) {
|
||||
upload( allObsToUpload[currentUploadIndex] );
|
||||
}
|
||||
}, [
|
||||
@@ -50,8 +36,7 @@ const useUploadObservations = ( allObsToUpload: Array<Object> ): Object => {
|
||||
] );
|
||||
|
||||
return {
|
||||
handleClosePress,
|
||||
status
|
||||
handleClosePress
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const useUserLocation = ( ): Object => {
|
||||
const permission = await request( PERMISSIONS.IOS.LOCATION_WHEN_IN_USE );
|
||||
return permission;
|
||||
} catch ( e ) {
|
||||
console.log( e, ": error requesting iOS permissions" );
|
||||
console.warn( e, ": error requesting iOS permissions" );
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -43,7 +43,7 @@ const useUserLocation = ( ): Object => {
|
||||
};
|
||||
|
||||
// TODO: set geolocation fetch error
|
||||
const failure = error => console.log( error.code, error.message );
|
||||
const failure = error => console.warn( error.code, error.message );
|
||||
|
||||
const options = { enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 };
|
||||
|
||||
|
||||
28
src/sharedHooks/useUserMe.js
Normal file
28
src/sharedHooks/useUserMe.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import { fetchUserMe } from "api/users";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
const useUserMe = ( ): Object => {
|
||||
const currentUser = useCurrentUser( );
|
||||
|
||||
const {
|
||||
data: remoteUser,
|
||||
isLoading,
|
||||
refetch: refetchUserMe
|
||||
} = useAuthenticatedQuery(
|
||||
["fetchUserMe"],
|
||||
optsWithAuth => fetchUserMe( { }, optsWithAuth ),
|
||||
{
|
||||
enabled: !!currentUser
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
remoteUser,
|
||||
isLoading,
|
||||
refetchUserMe
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserMe;
|
||||
@@ -1,6 +1,7 @@
|
||||
import factory, { define } from "factoria";
|
||||
|
||||
export default define( "LocalObservation", faker => ( {
|
||||
_synced_at: faker.date.past( ),
|
||||
_created_at: faker.date.past( ),
|
||||
uuid: faker.datatype.uuid( ),
|
||||
comments: [
|
||||
@@ -20,5 +21,8 @@ export default define( "LocalObservation", faker => ( {
|
||||
qualityGrade: "research",
|
||||
latitude: Number( faker.address.latitude( ) ),
|
||||
longitude: Number( faker.address.longitude( ) ),
|
||||
description: faker.lorem.paragraph( )
|
||||
description: faker.lorem.paragraph( ),
|
||||
// is this the right way to test this?
|
||||
needsSync: jest.fn( ),
|
||||
observed_on_string: "2022-12-03T11:14:16"
|
||||
} ) );
|
||||
|
||||
@@ -22,7 +22,7 @@ export default define( "RemoteObservation", faker => {
|
||||
geojson: {
|
||||
coordinates: [1, 1]
|
||||
},
|
||||
created_at: createdAt,
|
||||
created_at: "2022-11-27T19:07:41-08:00",
|
||||
updated_at: createdAt,
|
||||
time_observed_at: createdAt,
|
||||
location: "1,1",
|
||||
|
||||
@@ -65,18 +65,21 @@ describe( "MyObservations", ( ) => {
|
||||
} );
|
||||
} );
|
||||
|
||||
it( "should be Spanish if signed in user's locale is Spanish", async ( ) => {
|
||||
const mockSpanishUser = factory( "LocalUser", {
|
||||
locale: "es"
|
||||
} );
|
||||
expect( mockSpanishUser.locale ).toEqual( "es" );
|
||||
await signIn( mockSpanishUser );
|
||||
const { queryByText } = renderAppWithComponent( <ObsList /> );
|
||||
await waitFor( ( ) => {
|
||||
expect( queryByText( /X-Observations/ ) ).toBeFalsy( );
|
||||
expect( queryByText( / Observaciones/ ) ).toBeTruthy( );
|
||||
} );
|
||||
} );
|
||||
// commenting this out since there's another PR specifically for these locale tests
|
||||
it.todo( "should be Spanish if signed in user's locale is Spanish" );
|
||||
|
||||
// it( "should be Spanish if signed in user's locale is Spanish", async ( ) => {
|
||||
// const mockSpanishUser = factory( "LocalUser", {
|
||||
// locale: "es"
|
||||
// } );
|
||||
// expect( mockSpanishUser.locale ).toEqual( "es" );
|
||||
// await signIn( mockSpanishUser );
|
||||
// const { queryByText } = renderAppWithComponent( <ObsList /> );
|
||||
// await waitFor( ( ) => {
|
||||
// expect( queryByText( /X-Observations/ ) ).toBeFalsy( );
|
||||
// expect( queryByText( / Observaciones/ ) ).toBeTruthy( );
|
||||
// } );
|
||||
// } );
|
||||
it.todo( "should change to es when local user locale is en but remote user locale is es" );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -10,7 +10,11 @@ import mockRNLocalize from "react-native-localize/mock";
|
||||
import mockSafeAreaContext from "react-native-safe-area-context/jest/mock";
|
||||
|
||||
import { makeResponse } from "./factory";
|
||||
import { mockCamera, mockSortDevices } from "./vision-camera/vision-camera";
|
||||
import {
|
||||
mockCamera,
|
||||
mockSortDevices,
|
||||
mockUseCameraDevices
|
||||
} from "./vision-camera/vision-camera";
|
||||
|
||||
jest.mock(
|
||||
"@react-native-async-storage/async-storage",
|
||||
@@ -21,7 +25,8 @@ require( "react-native-reanimated/lib/reanimated2/jestUtils" ).setUpTests();
|
||||
|
||||
jest.mock( "react-native-vision-camera", ( ) => ( {
|
||||
Camera: mockCamera,
|
||||
sortDevices: mockSortDevices
|
||||
sortDevices: mockSortDevices,
|
||||
useCameraDevices: mockUseCameraDevices
|
||||
} ) );
|
||||
|
||||
jest.mock( "react-native-localize", () => mockRNLocalize );
|
||||
|
||||
65
tests/unit/components/Camera/StandardCamera.test.js
Normal file
65
tests/unit/components/Camera/StandardCamera.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react-native";
|
||||
import StandardCamera from "components/Camera/StandardCamera";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
||||
jest.mock( "@react-navigation/native", () => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: () => ( {
|
||||
navigate: mockedNavigate
|
||||
} ),
|
||||
useRoute: () => ( {} )
|
||||
};
|
||||
} );
|
||||
|
||||
const mockValue = {
|
||||
addCameraPhotosToCurrentObservation: jest.fn(),
|
||||
allObsPhotoUris: [],
|
||||
cameraPreviewUris: []
|
||||
};
|
||||
|
||||
const mockView = <View />;
|
||||
jest.mock( "components/Camera/CameraView", () => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => mockView
|
||||
} ) );
|
||||
|
||||
jest.mock( "components/Camera/FadeInOutView", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockView
|
||||
} ) );
|
||||
|
||||
jest.mock( "components/Camera/PhotoPreview", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockView
|
||||
} ) );
|
||||
|
||||
const renderStandardCamera = () => render(
|
||||
<ObsEditContext.Provider value={mockValue}>
|
||||
<StandardCamera />
|
||||
</ObsEditContext.Provider>
|
||||
);
|
||||
|
||||
describe( "StandardCamera", ( ) => {
|
||||
test( "should first render with flash disabled", async () => {
|
||||
renderStandardCamera();
|
||||
|
||||
await screen.findByTestId( "flash-button-label-flash-off" );
|
||||
} );
|
||||
|
||||
test( "should change to flash enabled on button press", async () => {
|
||||
renderStandardCamera();
|
||||
|
||||
const flashButton = await screen.findByTestId(
|
||||
"flash-button-label-flash-off"
|
||||
);
|
||||
fireEvent.press( flashButton );
|
||||
|
||||
await screen.findByTestId( "flash-button-label-flash" );
|
||||
} );
|
||||
} );
|
||||
@@ -7,6 +7,12 @@ import factory from "../../../factory";
|
||||
|
||||
const mockedNavigate = jest.fn( );
|
||||
const mockMessage = factory( "RemoteMessage" );
|
||||
const mockUser = factory( "LocalUser" );
|
||||
|
||||
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => mockUser
|
||||
} ) );
|
||||
|
||||
jest.mock( "@react-navigation/native", ( ) => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
|
||||
@@ -6,7 +6,10 @@ import factory from "../../../factory";
|
||||
import { renderComponent } from "../../../helpers/render";
|
||||
|
||||
const mockNavigate = jest.fn( );
|
||||
const mockObservation = factory( "LocalObservation" );
|
||||
const mockObservation = factory( "LocalObservation", {
|
||||
created_at: "2022-11-27T19:07:41-08:00",
|
||||
time_observed_at: "2023-12-14T21:07:41-09:30"
|
||||
} );
|
||||
const mockUser = factory( "LocalUser" );
|
||||
|
||||
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
|
||||
128
tests/unit/components/ObsEdit/DeleteObservationDialog.test.js
Normal file
128
tests/unit/components/ObsEdit/DeleteObservationDialog.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { fireEvent, waitFor } from "@testing-library/react-native";
|
||||
import DeleteObservationDialog from "components/ObsEdit/DeleteObservationDialog";
|
||||
import inatjs from "inaturalistjs";
|
||||
import { ObsEditContext } from "providers/contexts";
|
||||
import ObsEditProvider from "providers/ObsEditProvider";
|
||||
import React from "react";
|
||||
|
||||
import factory from "../../../factory";
|
||||
import { renderComponent } from "../../../helpers/render";
|
||||
|
||||
jest.useFakeTimers( );
|
||||
|
||||
beforeEach( async ( ) => {
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.deleteAll( );
|
||||
} );
|
||||
} );
|
||||
|
||||
afterEach( ( ) => {
|
||||
jest.clearAllMocks( );
|
||||
} );
|
||||
|
||||
jest.mock( "providers/ObsEditProvider" );
|
||||
|
||||
jest.mock( "@react-navigation/native", ( ) => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: ( ) => ( {
|
||||
navigate: jest.fn( )
|
||||
} )
|
||||
};
|
||||
} );
|
||||
|
||||
// Mock ObservationProvider so it provides a specific array of observations
|
||||
// without any current observation or ability to update or fetch
|
||||
// observations
|
||||
const mockObsEditProviderWithObs = obs => ObsEditProvider.mockImplementation( ( { children } ) => (
|
||||
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
||||
<ObsEditContext.Provider value={{
|
||||
currentObservation: obs[0],
|
||||
deleteLocalObservation: ( ) => {
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.delete( global.realm.objectForPrimaryKey( "Observation", obs[0].uuid ) );
|
||||
} );
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ObsEditContext.Provider>
|
||||
) );
|
||||
|
||||
const renderDeleteDialog = ( ) => renderComponent(
|
||||
<ObsEditProvider>
|
||||
<DeleteObservationDialog deleteDialogVisible hideDialog={( ) => jest.fn( )} />
|
||||
</ObsEditProvider>
|
||||
);
|
||||
|
||||
const getLocalObservation = uuid => global.realm
|
||||
.objectForPrimaryKey( "Observation", uuid );
|
||||
|
||||
describe( "delete observation", ( ) => {
|
||||
describe( "delete an unsynced observation", ( ) => {
|
||||
it( "should delete an observation from realm", async ( ) => {
|
||||
const observations = [factory( "LocalObservation", {
|
||||
_synced_at: null
|
||||
} )];
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.create( "Observation", observations[0] );
|
||||
} );
|
||||
const localObservation = getLocalObservation( observations[0].uuid );
|
||||
expect( localObservation ).toBeTruthy( );
|
||||
mockObsEditProviderWithObs( observations );
|
||||
const { queryByText } = renderDeleteDialog( );
|
||||
const deleteButton = queryByText( /Yes-delete-observation/ );
|
||||
expect( deleteButton ).toBeTruthy( );
|
||||
fireEvent.press( deleteButton );
|
||||
expect( getLocalObservation( observations[0].uuid ) ).toBeFalsy( );
|
||||
} );
|
||||
|
||||
it( "should not make a request to observations/delete", async ( ) => {
|
||||
await waitFor( ( ) => {
|
||||
expect( inatjs.observations.delete ).not.toHaveBeenCalled( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "delete a previously synced observation", ( ) => {
|
||||
it( "should make a request to observations/delete", async ( ) => {
|
||||
const observations = [factory( "LocalObservation" )];
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.create( "Observation", observations[0] );
|
||||
} );
|
||||
const localObservation = getLocalObservation( observations[0].uuid );
|
||||
expect( localObservation ).toBeTruthy( );
|
||||
mockObsEditProviderWithObs( observations );
|
||||
const { queryByText } = renderDeleteDialog( );
|
||||
// TODO: figure out why this needs English text and why the one above needs
|
||||
// the generic text. Probably has to do with User object still being stored in global realm
|
||||
// between tests
|
||||
const deleteButton = queryByText( /delete/ );
|
||||
expect( deleteButton ).toBeTruthy( );
|
||||
fireEvent.press( deleteButton );
|
||||
await waitFor( ( ) => {
|
||||
expect( inatjs.observations.delete ).toHaveBeenCalledTimes( 1 );
|
||||
expect( getLocalObservation( observations[0].uuid ) ).toBeFalsy( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "cancel deletion", ( ) => {
|
||||
it( "should not delete the observation from realm", ( ) => {
|
||||
const observations = [factory( "LocalObservation" )];
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.create( "Observation", observations[0] );
|
||||
} );
|
||||
const localObservation = getLocalObservation( observations[0].uuid );
|
||||
expect( localObservation ).toBeTruthy( );
|
||||
mockObsEditProviderWithObs( observations );
|
||||
const { queryByText } = renderDeleteDialog( );
|
||||
|
||||
const cancelButton = queryByText( /Cancel/ );
|
||||
expect( cancelButton ).toBeTruthy( );
|
||||
fireEvent.press( cancelButton );
|
||||
expect( getLocalObservation( observations[0].uuid ) ).toBeTruthy( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -32,11 +32,6 @@ jest.mock( "sharedHooks/useLocationName", ( ) => ( {
|
||||
default: ( ) => mockLocationName
|
||||
} ) );
|
||||
|
||||
jest.mock( "sharedHooks/useLoggedIn", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => true
|
||||
} ) );
|
||||
|
||||
jest.mock( "@react-navigation/native", ( ) => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
|
||||
@@ -9,20 +9,6 @@ const testObservation = factory( "LocalObservation" );
|
||||
|
||||
const qualityGradeText = t( "RG" );
|
||||
|
||||
// this probably isn't the right approach, but it does allow the test to pass
|
||||
jest.mock( "../../../../src/realmModels/index", ( ) => {
|
||||
const originalModule = jest.requireActual( "../../../../src/realmModels/index" );
|
||||
|
||||
// Mock the default export and named export 'foo'
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
default: {
|
||||
inMemory: true
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
test( "renders text passed into observation card", ( ) => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<ObsCard
|
||||
|
||||
@@ -20,12 +20,9 @@ const mockObservations = [
|
||||
|
||||
// Mock the hooks we use on ObsList since we're not trying to test them here
|
||||
|
||||
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => true
|
||||
} ) );
|
||||
jest.mock( "sharedHooks/useApiToken" );
|
||||
|
||||
jest.mock( "sharedHooks/useLoggedIn", ( ) => ( {
|
||||
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => true
|
||||
} ) );
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user