Obsedit add photo with StandardCamera, two fixes (#1677)

* Reorder hooks dependencies

* Return uri from take photo hook

* Keep state of photos added in this instance of the camera

* List2 TS

* INatIconButton TS

* Refactor useBackPress to show discard modal only if photos taken during this instance of the camera

* Remove newPhotoCount var

* TS refactors

* fetchUserLocation TS

* Increase timeout

* Fix error

* Hoist deletePhotoByUri

* Delete photos on discard

* Reorder code

* Set saving photo on checkmark press

Closes #1556

* Update snapshots

* Remove delete test

* Create StandardCamera.test.js

* Check if image is there before deletion

* Update react-native-share-menu+6.0.0.patch

* Update e2e_ios.yml

* Update some types
This commit is contained in:
Johannes Klein
2024-06-14 14:04:24 +02:00
committed by GitHub
parent 6d553daa0b
commit 7c91bdf950
22 changed files with 317 additions and 275 deletions

View File

@@ -16,7 +16,7 @@ concurrency:
jobs:
checksecret:
name: check for oauth client
runs-on: macos-14
runs-on: macos-13
outputs:
is_SECRETS_PRESENT_set: ${{ steps.checksecret_job.outputs.is_SECRETS_PRESENT_set }}
steps:
@@ -131,7 +131,7 @@ jobs:
name: Notify Slack
needs: test
if: ${{ success() || failure() }}
runs-on: macos-14
runs-on: macos-13
steps:
- uses: iRoachie/slack-github-actions@v2.3.0
if: env.SLACK_WEBHOOK_URL != null

View File

@@ -8,7 +8,7 @@ index 9557fdb..ebdeb6f 100644
android {
- compileSdkVersion 29
- buildToolsVersion "29.0.2"
+ compileSdkVersion 31
+ compileSdkVersion 33
+ buildToolsVersion "31.0.0"
defaultConfig {

View File

@@ -1,11 +1,7 @@
// @flow
import classnames from "classnames";
import MediaViewerModal from "components/MediaViewer/MediaViewerModal";
import { ActivityIndicator, INatIconButton } from "components/SharedComponents";
import { ImageBackground, Pressable, View } from "components/styledComponents";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useCallback, useEffect, useRef, useState
} from "react";
@@ -18,53 +14,49 @@ import Animated, {
useAnimatedStyle,
withTiming
} from "react-native-reanimated";
import ObservationPhoto from "realmModels/ObservationPhoto";
import { useTranslation } from "sharedHooks";
import useStore from "stores/useStore";
const { useRealm } = RealmContext;
type Props = {
emptyComponent?: Function,
takingPhoto?: boolean,
isLandscapeMode?:boolean,
isLargeScreen?: boolean,
isTablet?: boolean,
interface Props {
takingPhoto?: boolean;
isLandscapeMode?: boolean;
isLargeScreen?: boolean;
isTablet?: boolean;
rotation?: {
value: number
},
photoUris: Array<string>
};
photoUris: string[];
onDelete: ( _uri: string ) => void;
}
export const SMALL_PHOTO_DIM = 42;
export const LARGE_PHOTO_DIM = 83;
export const SMALL_PHOTO_GUTTER = 6;
export const LARGE_PHOTO_GUTTER = 17;
const IMAGE_CONTAINER_CLASSES = ["justify-center", "items-center"];
const IMAGE_CONTAINER_CLASSES = ["justify-center", "items-center"] as const;
const SMALL_PHOTO_CLASSES = [
"rounded-sm",
"w-[42px]",
"h-[42x]",
"mx-[3px]"
];
] as const;
const LARGE_PHOTO_CLASSES = [
"rounded-md",
"w-[83px]",
"h-[83px]",
"m-[8.5px]"
];
] as const;
const PhotoCarousel = ( {
emptyComponent,
takingPhoto,
isLandscapeMode,
isLargeScreen,
isTablet,
rotation,
photoUris
}: Props ): Node => {
photoUris,
onDelete
}: Props ) => {
const deletePhotoFromObservation = useStore( state => state.deletePhotoFromObservation );
const realm = useRealm( );
const { t } = useTranslation( );
const theme = useTheme( );
const [deletePhotoMode, setDeletePhotoMode] = useState( false );
@@ -92,7 +84,7 @@ const PhotoCarousel = ( {
{
rotateZ: rotation
? withTiming( `${-1 * rotation.value}deg` )
: 0
: "0"
}
]
} ),
@@ -133,19 +125,16 @@ const PhotoCarousel = ( {
}
}, [deletePhotoFromObservation] );
const viewPhotoAtIndex = useCallback( ( item, index ) => {
const viewPhotoAtIndex = useCallback( ( index: number ) => {
setTappedPhotoIndex( index );
}, [
setTappedPhotoIndex
] );
const deletePhotoByUri = useCallback( async photoUri => {
if ( !deletePhotoFromObservation ) return;
deletePhotoFromObservation( photoUri );
await ObservationPhoto.deletePhoto( realm, photoUri );
}, [deletePhotoFromObservation, realm] );
const renderPhotoOrEvidenceButton = useCallback( ( { item: photoUri, index } ) => (
const renderPhotoOrEvidenceButton = useCallback( ( {
item: photoUri,
index
} ) => (
<>
{index === 0 && renderSkeleton( )}
<Animated.View style={!isTablet && animatedStyle}>
@@ -181,7 +170,7 @@ const PhotoCarousel = ( {
backgroundColor="rgba(0, 0, 0, 0.5)"
testID={`PhotoCarousel.deletePhoto.${photoUri}`}
accessibilityLabel={t( "Delete-photo" )}
onPress={( ) => deletePhotoByUri( photoUri )}
onPress={( ) => onDelete( photoUri )}
/>
</View>
)
@@ -191,7 +180,7 @@ const PhotoCarousel = ( {
accessibilityLabel={t( "View-photo" )}
testID={`PhotoCarousel.displayPhoto.${photoUri}`}
onLongPress={showDeletePhotoMode}
onPress={( ) => viewPhotoAtIndex( photoUri, index )}
onPress={( ) => viewPhotoAtIndex( index )}
className="w-full h-full"
/>
)
@@ -203,7 +192,7 @@ const PhotoCarousel = ( {
</>
), [
animatedStyle,
deletePhotoByUri,
onDelete,
deletePhotoMode,
isTablet,
photoClasses,
@@ -221,7 +210,7 @@ const PhotoCarousel = ( {
horizontal={!isTablet || !isLandscapeMode}
ListEmptyComponent={takingPhoto
? renderSkeleton( )
: emptyComponent}
: null}
/>
);
@@ -235,8 +224,13 @@ const PhotoCarousel = ( {
// state, and use that to position another container inside the modal in
// exactly the same place
const containerRef = useRef( );
const [containerPos, setContainerPos] = useState( { x: null, y: null } );
const containerRef = useRef<View>( );
const [containerPos, setContainerPos] = useState<{
x: number | null;
y: number | null;
w?: number;
h?: number;
}>( { x: null, y: null } );
const containerStyle = {
height: isTablet && isLandscapeMode
? photoUris.length * ( photoDim + photoGutter ) + photoGutter
@@ -299,8 +293,8 @@ const PhotoCarousel = ( {
editable
showModal={tappedPhotoIndex >= 0}
onClose={( ) => setTappedPhotoIndex( -1 )}
onDeletePhoto={async photoUri => {
await deletePhotoByUri( photoUri );
onDeletePhoto={async ( photoUri: string ) => {
await onDelete( photoUri );
setTappedPhotoIndex( tappedPhotoIndex - 1 );
}}
uri={photoUris[tappedPhotoIndex]}

View File

@@ -1,8 +1,6 @@
// @flow
import classnames from "classnames";
import { Subheading1 } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "sharedHooks";
@@ -13,22 +11,23 @@ import PhotoCarousel, {
SMALL_PHOTO_GUTTER
} from "./PhotoCarousel";
type Props = {
interface Props {
rotation?: {
value: number
},
isLandscapeMode?: boolean,
isLargeScreen?: boolean,
isTablet?: boolean,
takingPhoto: boolean,
rotatedOriginalCameraPhotos: Function,
};
isLandscapeMode?: boolean;
isLargeScreen?: boolean;
isTablet?: boolean;
takingPhoto: boolean;
rotatedOriginalCameraPhotos: string[];
onDelete: ( _uri: string ) => void;
}
const STYLE = {
justifyContent: "center",
flex: 0,
flexShrink: 1
};
} as const;
const PhotoPreview = ( {
isLandscapeMode,
@@ -36,8 +35,9 @@ const PhotoPreview = ( {
isTablet,
rotation,
takingPhoto,
rotatedOriginalCameraPhotos
}: Props ): Node => {
rotatedOriginalCameraPhotos,
onDelete
}: Props ) => {
const { t } = useTranslation( );
const wrapperDim = isLargeScreen
? LARGE_PHOTO_DIM + LARGE_PHOTO_GUTTER * 2
@@ -74,7 +74,10 @@ const PhotoPreview = ( {
);
}
const dynamicStyle = {};
const dynamicStyle: {
width?: number | string;
height?: number;
} = {};
if ( isTablet && isLandscapeMode ) {
dynamicStyle.width = wrapperDim;
} else {
@@ -84,7 +87,6 @@ const PhotoPreview = ( {
return (
<View
// eslint-disable-next-line react-native/no-inline-styles
style={[STYLE, dynamicStyle]}
>
{
@@ -98,6 +100,7 @@ const PhotoPreview = ( {
isLargeScreen={isLargeScreen}
isTablet={isTablet}
isLandscapeMode={isLandscapeMode}
onDelete={onDelete}
/>
)
}

View File

@@ -1,15 +1,14 @@
// @flow
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import CameraView from "components/Camera/CameraView.tsx";
import FadeInOutView from "components/Camera/FadeInOutView";
import useRotation from "components/Camera/hooks/useRotation";
import useTakePhoto from "components/Camera/hooks/useTakePhoto.ts";
import useZoom from "components/Camera/hooks/useZoom.ts";
import navigateToObsDetails from "components/ObsDetails/helpers/navigateToObsDetails";
import { View } from "components/styledComponents";
import { getCurrentRoute } from "navigation/navigationUtils";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useCallback,
@@ -19,6 +18,7 @@ import React, {
} from "react";
import DeviceInfo from "react-native-device-info";
import { Snackbar } from "react-native-paper";
import ObservationPhoto from "realmModels/ObservationPhoto";
import { BREAKPOINTS } from "sharedHelpers/breakpoint";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation";
import useTranslation from "sharedHooks/useTranslation";
@@ -36,6 +36,8 @@ import DiscardChangesSheet from "./DiscardChangesSheet";
import useBackPress from "./hooks/useBackPress";
import PhotoPreview from "./PhotoPreview";
const { useRealm } = RealmContext;
const isTablet = DeviceInfo.isTablet( );
export const MAX_PHOTOS_ALLOWED = 20;
@@ -57,6 +59,7 @@ const StandardCamera = ( {
handleCheckmarkPress,
isLandscapeMode
}: Props ): Node => {
const realm = useRealm( );
const hasFlash = device?.hasFlash;
const {
animatedProps,
@@ -71,33 +74,6 @@ const StandardCamera = ( {
rotation
} = useRotation( );
const navigation = useNavigation( );
const { params } = useRoute();
const onBack = () => {
const currentRoute = getCurrentRoute();
if ( currentRoute?.params?.addEvidence ) {
navigation.navigate( "ObsEdit" );
} else {
const previousScreen = params && params.previousScreen
? params.previousScreen
: null;
if ( previousScreen && previousScreen.name === "ObsDetails" ) {
navigateToObsDetails( navigation, previousScreen.params.uuid );
} else {
navigation.navigate( "TabNavigator", {
screen: "TabStackNavigator",
params: {
screen: "ObsList"
}
} );
}
}
};
const {
handleBackButtonPress,
setShowDiscardSheet,
showDiscardSheet
} = useBackPress( onBack );
const {
takePhoto,
takePhotoOptions,
@@ -110,6 +86,7 @@ const StandardCamera = ( {
const rotatedOriginalCameraPhotos = useStore( state => state.rotatedOriginalCameraPhotos );
const resetEvidenceToAdd = useStore( state => state.resetEvidenceToAdd );
const galleryUris = useStore( state => state.galleryUris );
const deletePhotoFromObservation = useStore( state => state.deletePhotoFromObservation );
const totalObsPhotoUris = useMemo(
( ) => [...rotatedOriginalCameraPhotos, ...galleryUris].length,
@@ -119,25 +96,37 @@ const StandardCamera = ( {
const disallowAddingPhotos = totalObsPhotoUris >= MAX_PHOTOS_ALLOWED;
const [showAlert, setShowAlert] = useState( false );
const [dismissChanges, setDismissChanges] = useState( false );
const [newPhotoCount, setNewPhotoCount] = useState( 0 );
const [newPhotoUris, setNewPhotoUris] = useState( [] );
const { screenWidth } = useDeviceOrientation( );
// newPhotoCount tracks photos taken in *this* instance of the camera. The
// newPhotoUris tracks photos taken in *this* instance of the camera. The
// camera might be instantiated with several rotatedOriginalCameraPhotos or
// galleryUris already in state, but we only want to show the CTA button
// galleryUris already in state, but we only want to show the CTA button or discard modal
// when the user has taken a photo with *this* instance of the camera
const photosTaken = newPhotoCount > 0 && totalObsPhotoUris > 0;
const photosTaken = newPhotoUris.length > 0 && totalObsPhotoUris > 0;
const {
handleBackButtonPress,
setShowDiscardSheet,
showDiscardSheet
} = useBackPress( photosTaken );
useFocusEffect(
useCallback( ( ) => {
// Reset camera zoom every time we get into a fresh camera view
resetZoom( );
resetEvidenceToAdd( );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] )
);
const deletePhotoByUri = useCallback( async ( photoUri: string ) => {
if ( !deletePhotoFromObservation ) return;
deletePhotoFromObservation( photoUri );
await ObservationPhoto.deletePhoto( realm, photoUri );
setNewPhotoUris( newPhotoUris.filter( uri => uri !== photoUri ) );
}, [deletePhotoFromObservation, realm, newPhotoUris] );
useEffect( ( ) => {
// We do this navigation indirectly (vs doing it directly in DiscardChangesSheet),
// since we need for the bottom sheet of discard-changes to first finish dismissing,
@@ -145,19 +134,21 @@ const StandardCamera = ( {
// to sometimes pop back up on the next screen - see GH issue #629
if ( !showDiscardSheet ) {
if ( dismissChanges ) {
// TODO delete any new photos taken
newPhotoUris.forEach( uri => {
deletePhotoByUri( uri );
} );
navigation.goBack();
}
}
}, [dismissChanges, showDiscardSheet, navigation] );
}, [dismissChanges, showDiscardSheet, navigation, newPhotoUris, deletePhotoByUri] );
const handleTakePhoto = async ( ) => {
if ( disallowAddingPhotos ) {
setShowAlert( true );
return;
}
await takePhoto( );
setNewPhotoCount( newPhotoCount + 1 );
const uri = await takePhoto( );
setNewPhotoUris( [...newPhotoUris, uri] );
};
const containerClasses = ["flex-1"];
@@ -174,6 +165,7 @@ const StandardCamera = ( {
isLargeScreen={screenWidth > BREAKPOINTS.md}
isTablet={isTablet}
rotatedOriginalCameraPhotos={rotatedOriginalCameraPhotos}
onDelete={deletePhotoByUri}
/>
<View className="relative flex-1">
{device && (

View File

@@ -1,48 +0,0 @@
// @flow
import { useFocusEffect } from "@react-navigation/native";
import {
useCallback,
useState
} from "react";
import {
BackHandler
} from "react-native";
import useStore from "stores/useStore";
const useBackPress = ( onBack: Function ): Object => {
const [showDiscardSheet, setShowDiscardSheet] = useState( false );
const rotatedOriginalCameraPhotos = useStore( state => state.rotatedOriginalCameraPhotos );
const handleBackButtonPress = useCallback( ( ) => {
if ( rotatedOriginalCameraPhotos.length > 0 ) {
setShowDiscardSheet( true );
} else {
onBack();
}
}, [setShowDiscardSheet, rotatedOriginalCameraPhotos, onBack] );
useFocusEffect(
// note: cannot use navigation.addListener to trigger bottom sheet in tab navigator
// since the screen is unfocused, not removed from navigation
useCallback( ( ) => {
// make sure an Android user cannot back out and accidentally discard photos
const onBackPress = ( ) => {
handleBackButtonPress( );
return true;
};
BackHandler.addEventListener( "hardwareBackPress", onBackPress );
return ( ) => BackHandler.removeEventListener( "hardwareBackPress", onBackPress );
}, [handleBackButtonPress] )
);
return {
handleBackButtonPress,
setShowDiscardSheet,
showDiscardSheet
};
};
export default useBackPress;

View File

@@ -0,0 +1,67 @@
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
import navigateToObsDetails from "components/ObsDetails/helpers/navigateToObsDetails";
import { getCurrentRoute } from "navigation/navigationUtils.ts";
import {
useCallback,
useState
} from "react";
import {
BackHandler
} from "react-native";
const useBackPress = ( shouldShowDiscardSheet: boolean ) => {
const navigation = useNavigation( );
const { params } = useRoute();
const [showDiscardSheet, setShowDiscardSheet] = useState( false );
const handleBackButtonPress = useCallback( ( ) => {
if ( shouldShowDiscardSheet ) {
setShowDiscardSheet( true );
} else {
const currentRoute = getCurrentRoute();
if ( currentRoute?.params?.addEvidence ) {
navigation.navigate( "ObsEdit" );
} else {
const previousScreen = params && params.previousScreen
? params.previousScreen
: null;
if ( previousScreen && previousScreen.name === "ObsDetails" ) {
navigateToObsDetails( navigation, previousScreen.params.uuid );
} else {
navigation.navigate( "TabNavigator", {
screen: "TabStackNavigator",
params: {
screen: "ObsList"
}
} );
}
}
}
}, [shouldShowDiscardSheet, setShowDiscardSheet, navigation, params] );
useFocusEffect(
// note: cannot use navigation.addListener to trigger bottom sheet in tab navigator
// since the screen is unfocused, not removed from navigation
useCallback( ( ) => {
// make sure an Android user cannot back out and accidentally discard photos
const onBackPress = ( ) => {
handleBackButtonPress( );
return true;
};
BackHandler.addEventListener( "hardwareBackPress", onBackPress );
return ( ) => BackHandler.removeEventListener( "hardwareBackPress", onBackPress );
}, [handleBackButtonPress] )
);
return {
handleBackButtonPress,
setShowDiscardSheet,
showDiscardSheet
};
};
export default useBackPress;

View File

@@ -36,6 +36,7 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
const addCameraRollUri = useStore( state => state.addCameraRollUri );
const currentObservationIndex = useStore( state => state.currentObservationIndex );
const observations = useStore( state => state.observations );
const setSavingPhoto = useStore( state => state.setSavingPhoto );
const { userLocation } = useUserLocation( { untilAcc: 5, enabled: !!shouldFetchLocation } );
const numOfObsPhotos = currentObservation?.observationPhotos?.length || 0;
@@ -137,6 +138,7 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
const prepareStoreAndNavigate = useCallback( async ( visionResult = null ) => {
if ( !checkmarkTapped ) { return null; }
setSavingPhoto( true );
// save all to camera roll
// handle case where user backs out from ObsEdit -> Suggestions -> Camera
@@ -163,7 +165,8 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
createObsWithCameraPhotos,
currentObservation,
navigation,
updateObsWithCameraPhotos
updateObsWithCameraPhotos,
setSavingPhoto
] );
return prepareStoreAndNavigate;

View File

@@ -2,6 +2,9 @@ import { RealmContext } from "providers/contexts";
import {
useState
} from "react";
import {
Camera, CameraDevice, PhotoFile, TakePhotoOptions
} from "react-native-vision-camera";
import ObservationPhoto from "realmModels/ObservationPhoto";
import {
rotatePhotoPatch,
@@ -12,24 +15,29 @@ import useStore from "stores/useStore";
const { useRealm } = RealmContext;
const useTakePhoto = ( camera: Object, addEvidence?: boolean, device?: Object ): Object => {
const useTakePhoto = (
camera: React.RefObject<Camera>,
addEvidence?: boolean,
device?: CameraDevice
): Object => {
const realm = useRealm( );
const currentObservation = useStore( state => state.currentObservation );
const { deviceOrientation } = useDeviceOrientation( );
const hasFlash = device?.hasFlash;
const initialPhotoOptions = {
enableShutterSound: true,
...( hasFlash && { flash: "off" } )
};
const deletePhotoFromObservation = useStore( state => state.deletePhotoFromObservation );
const [takePhotoOptions, setTakePhotoOptions] = useState( initialPhotoOptions );
const [takingPhoto, setTakingPhoto] = useState( false );
const currentObservation = useStore( state => state.currentObservation );
const deletePhotoFromObservation = useStore( state => state.deletePhotoFromObservation );
const setCameraState = useStore( state => state.setCameraState );
const evidenceToAdd = useStore( state => state.evidenceToAdd );
const rotatedOriginalCameraPhotos = useStore( state => state.rotatedOriginalCameraPhotos );
const saveRotatedPhotoToDocumentsDirectory = async cameraPhoto => {
const hasFlash = device?.hasFlash;
const initialPhotoOptions = {
enableShutterSound: true,
...( hasFlash && { flash: "off" } as const )
} as const;
const [takePhotoOptions, setTakePhotoOptions] = useState<TakePhotoOptions>( initialPhotoOptions );
const [takingPhoto, setTakingPhoto] = useState( false );
const saveRotatedPhotoToDocumentsDirectory = async ( cameraPhoto: PhotoFile ) => {
// Rotate the original photo depending on device orientation
const photoRotation = rotationTempPhotoPatch( cameraPhoto, deviceOrientation );
return rotatePhotoPatch( cameraPhoto, photoRotation );
@@ -70,6 +78,7 @@ const useTakePhoto = ( camera: Object, addEvidence?: boolean, device?: Object ):
const uri = await saveRotatedPhotoToDocumentsDirectory( cameraPhoto );
await updateStore( uri, options );
setTakingPhoto( false );
return uri;
};
const toggleFlash = ( ) => {

View File

@@ -5,7 +5,7 @@ import AddObsModal from "components/AddObsModal";
import { Modal } from "components/SharedComponents";
import GradientButton from "components/SharedComponents/Buttons/GradientButton";
import { t } from "i18next";
import { getCurrentRoute } from "navigation/navigationUtils";
import { getCurrentRoute } from "navigation/navigationUtils.ts";
import * as React from "react";
import { log } from "sharedHelpers/logger";
import useStore from "stores/useStore";

View File

@@ -1,32 +1,33 @@
// @flow
import classnames from "classnames";
import { INatIcon } from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { Platform, Pressable } from "react-native";
import {
GestureResponderEvent,
Platform,
Pressable,
ViewStyle
} from "react-native";
import { useTheme } from "react-native-paper";
type Props = {
accessibilityHint?: string,
accessibilityLabel: string,
// $FlowIgnore
children?: unknown,
color?: string,
disabled?: boolean,
height?: number,
icon: string,
onPress: Function,
interface Props {
accessibilityHint?: string;
accessibilityLabel: string;
children?: React.ReactNode;
color?: string;
disabled?: boolean;
height?: number;
icon: string;
onPress: ( _event: GestureResponderEvent ) => void;
// Inserts a white or colored view under the icon so an holes in the shape show as
// white
preventTransparency?: boolean,
size?: number,
style?: Object,
testID?: string,
width?: number,
backgroundColor?: string,
mode?: "contained"
preventTransparency?: boolean;
size?: number;
style?: ViewStyle;
testID?: string;
width?: number;
backgroundColor?: string;
mode?: "contained";
}
const MIN_ACCESSIBLE_DIM = 44;
@@ -50,7 +51,7 @@ const INatIconButton = ( {
width = MIN_ACCESSIBLE_DIM,
backgroundColor,
mode
}: Props ): Node => {
}: Props ) => {
const theme = useTheme( );
// width || 0 is to placate flow. width should never be undefined because of
// the defaultProps, but I guess flow can't figure that out.
@@ -69,7 +70,7 @@ const INatIconButton = ( {
"Button needs an accessibility label"
);
}
const opacity = pressed => {
const opacity = ( pressed: boolean ) => {
if ( disabled ) {
return 0.5;
}
@@ -97,7 +98,7 @@ const INatIconButton = ( {
},
mode === "contained" && {
backgroundColor: preventTransparency
? null
? undefined
: backgroundColor,
borderRadius: 9999
},

View File

@@ -1,6 +1,6 @@
// @flow
import INatIconButton from "components/SharedComponents/Buttons/INatIconButton";
import INatIconButton from "components/SharedComponents/Buttons/INatIconButton.tsx";
import type { Node } from "react";
import React from "react";
import { Pressable } from "react-native";

View File

@@ -1,14 +1,9 @@
// @flow
import {
tailwindFontRegular
} from "appConstants/fontFamilies.ts";
import { tailwindFontRegular } from "appConstants/fontFamilies.ts";
import classnames from "classnames";
import type { Node } from "react";
import React from "react";
import { Text } from "react-native";
const List2 = ( props: Object ): Node => (
const List2 = ( props: Object ) => (
<Text
className={classnames(
"text-sm trailing-tight text-darkGray",

View File

@@ -1,14 +1,11 @@
// @flow
import {
tailwindFontRegular
} from "appConstants/fontFamilies.ts";
import classnames from "classnames";
import type { Node } from "react";
import React from "react";
import { Text } from "react-native";
const Subheading1 = ( props: Object ): Node => (
const Subheading1 = ( props: Object ) => (
<Text
className={classnames(
"text-xl trailing-tight text-darkGray",

View File

@@ -3,8 +3,7 @@
import { t } from "i18next";
import * as React from "react";
import { LatLng } from "react-native-maps";
import fetchUserLocation from "../sharedHelpers/fetchUserLocation";
import fetchUserLocation from "sharedHelpers/fetchUserLocation.ts";
export enum EXPLORE_ACTION {
CHANGE_SORT_BY = "CHANGE_SORT_BY",

View File

@@ -1,4 +1,4 @@
import Geolocation from "@react-native-community/geolocation";
import Geolocation, { GeolocationResponse } from "@react-native-community/geolocation";
import {
LOCATION_PERMISSIONS,
permissionResultFromMultiple
@@ -12,20 +12,20 @@ import {
const options = {
enableHighAccuracy: true,
maximumAge: 0
};
maximumAge: 0,
timeout: 2000
} as const;
const getCurrentPosition = ( ) => new Promise(
const getCurrentPosition = ( ): Promise<GeolocationResponse> => new Promise(
( resolve, error ) => {
Geolocation.getCurrentPosition( resolve, error, options );
}
);
type UserLocation = {
latitude: number,
longitude: number,
positional_accuracy: number
interface UserLocation {
latitude: number;
longitude: number;
positional_accuracy: number;
}
const fetchUserLocation = async ( ): Promise<UserLocation | null> => {
const permissionResult = permissionResultFromMultiple(

View File

@@ -10,9 +10,7 @@ import {
useState
} from "react";
import { checkMultiple, RESULTS } from "react-native-permissions";
// For some reason this doesn't work with the path alias *and* a typescript file
import fetchUserLocation from "../sharedHelpers/fetchUserLocation";
import fetchUserLocation from "sharedHelpers/fetchUserLocation.ts";
const INITIAL_POSITIONAL_ACCURACY = 99999;
const TARGET_POSITIONAL_ACCURACY = 10;

View File

@@ -23,7 +23,7 @@ const DEFAULT_STATE = {
const removeObsPhotoFromObservation = ( currentObservation, uri ) => {
if ( _.isEmpty( currentObservation ) ) { return []; }
const updatedObservation = currentObservation;
const obsPhotos = Array.from( currentObservation?.observationPhotos );
const obsPhotos = Array.from( currentObservation?.observationPhotos || [] );
if ( obsPhotos.length > 0 ) {
// FYI, _.remove edits the array in place and returns the items you
// removed
@@ -109,11 +109,11 @@ const createObservationFlowSlice = set => ( {
savingPhoto: false
} );
} ),
setSavingPhoto: saving => set( { savingPhoto: saving } ),
setCameraState: options => set( state => ( {
evidenceToAdd: options?.evidenceToAdd || state.evidenceToAdd,
rotatedOriginalCameraPhotos:
options?.rotatedOriginalCameraPhotos || state.rotatedOriginalCameraPhotos,
savingPhoto: options?.evidenceToAdd?.length > 0 || state.savingPhoto
options?.rotatedOriginalCameraPhotos || state.rotatedOriginalCameraPhotos
} ) ),
setCurrentObservationIndex: index => set( state => ( {
currentObservationIndex: index,

View File

@@ -1,7 +1,7 @@
import {
fireEvent, render, screen, waitFor
render, screen
} from "@testing-library/react-native";
import PhotoCarousel from "components/Camera/StandardCamera/PhotoCarousel";
import PhotoCarousel from "components/Camera/StandardCamera/PhotoCarousel.tsx";
import React from "react";
import useStore from "stores/useStore";
@@ -45,56 +45,4 @@ describe( "PhotoCarousel", ( ) => {
// Snapshot test
expect( screen ).toMatchSnapshot();
} );
it( "deletes a photo on long press", async ( ) => {
const removePhotoFromList = ( list, photo ) => {
const i = list.findIndex( p => p === photo );
list.splice( i, 1 );
return list || [];
};
useStore.setState( {
evidenceToAdd: [mockPhotoUris[2]],
rotatedOriginalCameraPhotos: mockPhotoUris,
deletePhotoFromObservation: uri => useStore.setState( {
rotatedOriginalCameraPhotos: [...removePhotoFromList( mockPhotoUris, uri )]
} )
} );
const { rotatedOriginalCameraPhotos } = useStore.getState( );
render(
<PhotoCarousel
photoUris={rotatedOriginalCameraPhotos}
/>
);
const photoImage = screen.getByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[2]}`
);
fireEvent( photoImage, "onLongPress" );
const deleteMode = screen.getByTestId(
`PhotoCarousel.deletePhoto.${rotatedOriginalCameraPhotos[2]}`
);
await waitFor( ( ) => {
expect( deleteMode ).toBeVisible( );
} );
fireEvent.press( deleteMode );
render(
<PhotoCarousel
photoUris={rotatedOriginalCameraPhotos}
/>
);
const undeletedPhoto = screen.getByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[1]}`
);
expect( undeletedPhoto ).toBeVisible( );
const deletedPhoto = screen.queryByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[2]}`
);
await waitFor( ( ) => {
expect( deletedPhoto ).toBeFalsy( );
} );
} );
} );

View File

@@ -0,0 +1,82 @@
import {
fireEvent, render, screen, waitFor
} from "@testing-library/react-native";
import StandardCamera from "components/Camera/StandardCamera/StandardCamera";
import React from "react";
import useStore from "stores/useStore";
jest.mock( "components/MediaViewer/MediaViewerModal", ( ) => jest.fn( ( ) => null ) );
const initialStoreState = useStore.getState( );
const mockPhotoUris = [
"https://inaturalist-open-data.s3.amazonaws.com/photos/1/large.jpeg",
"https://inaturalist-open-data.s3.amazonaws.com/photos/2/large.jpeg",
"https://inaturalist-open-data.s3.amazonaws.com/photos/3/large.jpeg"
];
describe( "StandardCamera", ( ) => {
beforeAll( async () => {
useStore.setState( initialStoreState, true );
} );
it( "deletes a photo on long press", async ( ) => {
const removePhotoFromList = ( list, photo ) => {
const i = list.findIndex( p => p === photo );
list.splice( i, 1 );
return list || [];
};
useStore.setState( {
evidenceToAdd: [mockPhotoUris[2]],
rotatedOriginalCameraPhotos: mockPhotoUris,
deletePhotoFromObservation: uri => useStore.setState( {
rotatedOriginalCameraPhotos: [...removePhotoFromList( mockPhotoUris, uri )]
} )
} );
const { rotatedOriginalCameraPhotos } = useStore.getState( );
render(
<StandardCamera
camera={{}}
device={{}}
/>
);
const photoImage = screen.getByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[2]}`
);
const predeletedPhoto = screen.queryByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[2]}`
);
expect( predeletedPhoto ).toBeVisible( );
fireEvent( photoImage, "onLongPress" );
const deleteMode = screen.getByTestId(
`PhotoCarousel.deletePhoto.${rotatedOriginalCameraPhotos[2]}`
);
await waitFor( ( ) => {
expect( deleteMode ).toBeVisible( );
} );
fireEvent.press( deleteMode );
render(
<StandardCamera
camera={{}}
device={{}}
/>
);
const undeletedPhoto = screen.getByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[1]}`
);
expect( undeletedPhoto ).toBeVisible( );
const deletedPhoto = screen.queryByTestId(
`PhotoCarousel.displayPhoto.${rotatedOriginalCameraPhotos[2]}`
);
await waitFor( ( ) => {
expect( deletedPhoto ).toBeFalsy( );
} );
} );
} );

View File

@@ -13,6 +13,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
}
>
<RCTScrollView
ListEmptyComponent={null}
data={
[
"https://inaturalist-open-data.s3.amazonaws.com/photos/1/large.jpeg",
@@ -57,7 +58,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -67,7 +68,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}
@@ -226,7 +227,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -236,7 +237,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}
@@ -395,7 +396,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -405,7 +406,7 @@ exports[`PhotoCarousel renders correctly 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}
@@ -563,6 +564,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
}
>
<RCTScrollView
ListEmptyComponent={null}
data={
[
"https://inaturalist-open-data.s3.amazonaws.com/photos/1/large.jpeg",
@@ -607,7 +609,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -617,7 +619,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}
@@ -781,7 +783,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -791,7 +793,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}
@@ -955,7 +957,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
"value": {
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
},
@@ -965,7 +967,7 @@ exports[`PhotoCarousel renders correctly for large screen 1`] = `
{
"transform": [
{
"rotateZ": 0,
"rotateZ": "0",
},
],
}