Merge branch 'main' into 250-e2e-init

This commit is contained in:
Johannes Klein
2023-01-02 19:25:58 +01:00
106 changed files with 1723 additions and 1257 deletions

View File

@@ -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
View File

@@ -77,3 +77,6 @@ fastlane/Appfile
# Detox e2e test artifacts
artifacts/
*.log
# VisualStudioCode #
.vscode

View File

@@ -1,7 +0,0 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.tabSize": 2,
"eslint.validate": ["javascript"]
}

31
package-lock.json generated
View File

@@ -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
},

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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 } );
}
};

View File

@@ -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 );
}
};

View File

@@ -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 } );
}
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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 (

View File

@@ -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">

View File

@@ -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>

View File

@@ -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}

View File

@@ -23,7 +23,6 @@ const GridView = ( {
const renderGridItem = ( { item } ) => (
<GridItem
item={item}
handlePress={( ) => console.log( "press in identify" )}
reviewedIds={reviewedIds}
setReviewedIds={setReviewedIds}
/>

View File

@@ -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( )}

View File

@@ -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 (

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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;

View 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;

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -74,7 +74,6 @@ const AddEvidenceModal = ( {
const onRecordSound = () => {
// TODO - need to implement
console.log( "Record sound" );
};
return (

View File

@@ -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 (
<>

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -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"
/>

View File

@@ -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

View File

@@ -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" )}

View 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;

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" )}
/>
</>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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( ( ) => {

View 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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View 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;

View File

@@ -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>
);

View File

@@ -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( );

View File

@@ -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} />

View 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;

View File

@@ -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">&#x2304;</Text> );
const icon = ( ) => !noAlbums && <Text className="text-2xl">&#x2304;</Text>;
return (
<RNPickerSelect

View File

@@ -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 && (

View File

@@ -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] );

View File

@@ -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;

View File

@@ -27,6 +27,7 @@ const ProjectObservations = ( { id }: Props ): React.Node => {
const renderGridItem = ( { item } ) => (
<GridItem item={item} handlePress={navToObsDetails} uri="project" />
);
return (
<FlatList
data={observations}

View File

@@ -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 (
<>

View File

@@ -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>
);
};

View File

@@ -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( );

View File

@@ -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;

View File

@@ -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>

View File

@@ -21,6 +21,7 @@ const KebabMenu = ( { children, visible, setVisible }: Props ): Node => {
onPress={openMenu}
icon="dots-horizontal"
textColor={colors.logInGray}
testID="KebabMenu.Button"
/>
);

View File

@@ -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" );

View File

@@ -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 );
}
};

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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 (

View 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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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( ),

View File

@@ -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?"
}
}
}

View File

@@ -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 ) {

View File

@@ -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
};

View File

@@ -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;
}
};

View File

@@ -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;
};

View 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 );
};

View File

@@ -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 );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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" );
}
};

View File

@@ -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;

View File

@@ -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 ) );
}
} );

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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
};
};

View File

@@ -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 };

View 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;

View File

@@ -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"
} ) );

View File

@@ -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",

View File

@@ -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" );
} );
} );

View File

@@ -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 );

View 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" );
} );
} );

View File

@@ -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" );

View File

@@ -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", ( ) => ( {

View 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( );
} );
} );
} );

View File

@@ -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 {

View File

@@ -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

View File

@@ -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