mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
AI Camera gallery button (#1896)
* Add button to import photos from the gallery to the AI Camera * Move AI Camera buttons to side rails so it's easier to add new buttons * Got shutter button closer to spec * Change e2e to test for element absence instead of status text Closes #1848
This commit is contained in:
@@ -179,10 +179,13 @@ describe( "Signed in user", () => {
|
||||
/ 5. Delete the observations
|
||||
*/
|
||||
await deleteObservationByUUID( uuid, username, { uploaded: true } );
|
||||
// TODO test to make sure the exact observation we created was deleted.
|
||||
// Testing for the empty list UI isn't adequate because other test runs
|
||||
// happening in parallel might cause other observations to be there
|
||||
const deletedObservationText = element( by.text( /1 observation deleted/ ) );
|
||||
await waitFor( deletedObservationText ).toBeVisible().withTimeout( 10000 );
|
||||
// It would be nice to test for the "1 observation deleted" status text in
|
||||
// the toolbar, but that message appears ephemerally and sometimes this
|
||||
// test doesn't pick it up on the Github runner. Since we created two
|
||||
// observations, the upload prompt will be the stable status text at this
|
||||
// point, and we can confirm deletion by testing for the absence of the
|
||||
// list item for the observation we deleted.
|
||||
await waitFor( element( by.text( /Upload 1 observation/ ) ) ).toBeVisible( ).withTimeout( 10000 );
|
||||
await expect( obsListItem ).toBeNotVisible( );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import CameraFlip from "components/Camera/Buttons/CameraFlip.tsx";
|
||||
import Close from "components/Camera/Buttons/Close.tsx";
|
||||
import Flash from "components/Camera/Buttons/Flash.tsx";
|
||||
import Gallery from "components/Camera/Buttons/Gallery.tsx";
|
||||
import TakePhoto from "components/Camera/Buttons/TakePhoto.tsx";
|
||||
import Zoom from "components/Camera/Buttons/Zoom.tsx";
|
||||
import TabletButtons from "components/Camera/TabletButtons.tsx";
|
||||
@@ -67,6 +68,7 @@ const AICameraButtons = ( {
|
||||
disabled={!modelLoaded || takingPhoto}
|
||||
flipCamera={flipCamera}
|
||||
hasFlash={hasFlash}
|
||||
hasGalleryButton
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
showPrediction={showPrediction}
|
||||
showZoomButton={showZoomButton}
|
||||
@@ -78,48 +80,61 @@ const AICameraButtons = ( {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View className="bottom-10 absolute right-5 left-5">
|
||||
<View className="flex-row justify-end pb-[30px]">
|
||||
<Zoom
|
||||
zoomTextValue={zoomTextValue}
|
||||
changeZoom={changeZoom}
|
||||
showZoomButton={showZoomButton}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
/>
|
||||
<View className="bottom-10 absolute right-5 left-5" pointerEvents="box-none">
|
||||
<View
|
||||
className="absolute left-0 bottom-[17px] h-full justify-end flex gap-y-9"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View><Close /></View>
|
||||
</View>
|
||||
<AIDebugButton
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
setConfidenceThreshold={setConfidenceThreshold}
|
||||
fps={fps}
|
||||
setFPS={setFPS}
|
||||
numStoredResults={numStoredResults}
|
||||
setNumStoredResults={setNumStoredResults}
|
||||
cropRatio={cropRatio}
|
||||
setCropRatio={setCropRatio}
|
||||
// TODO: The following are just to get accessibility tests to pass...
|
||||
// without making anything truly accessible. The test seems to think
|
||||
// AIDebugButton is itself not accessible, but it's really
|
||||
// complaining about the sliders within. If the sliders make it into
|
||||
// production, they'll need to be made to pass that test.
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityValue={{ min: 0, max: 100, now: 50 }}
|
||||
/>
|
||||
<View className="flex-row justify-end pb-[20px]">
|
||||
<Flash
|
||||
toggleFlash={toggleFlash}
|
||||
hasFlash={hasFlash}
|
||||
takePhotoOptions={takePhotoOptions}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
<View
|
||||
className="absolute right-0 bottom-[6px] h-full justify-end items-end flex gap-y-9"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<AIDebugButton
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
setConfidenceThreshold={setConfidenceThreshold}
|
||||
fps={fps}
|
||||
setFPS={setFPS}
|
||||
numStoredResults={numStoredResults}
|
||||
setNumStoredResults={setNumStoredResults}
|
||||
cropRatio={cropRatio}
|
||||
setCropRatio={setCropRatio}
|
||||
// TODO: The following are just to get accessibility tests to pass...
|
||||
// without making anything truly accessible. The test seems to think
|
||||
// AIDebugButton is itself not accessible, but it's really
|
||||
// complaining about the sliders within. If the sliders make it into
|
||||
// production, they'll need to be made to pass that test.
|
||||
accessibilityRole="adjustable"
|
||||
accessibilityValue={{ min: 0, max: 100, now: 50 }}
|
||||
/>
|
||||
<View>
|
||||
<Zoom
|
||||
zoomTextValue={zoomTextValue}
|
||||
changeZoom={changeZoom}
|
||||
showZoomButton={showZoomButton}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Flash
|
||||
toggleFlash={toggleFlash}
|
||||
hasFlash={hasFlash}
|
||||
takePhotoOptions={takePhotoOptions}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
/>
|
||||
</View>
|
||||
<View><CameraFlip flipCamera={flipCamera} /></View>
|
||||
<View>
|
||||
<Gallery rotatableAnimatedStyle={rotatableAnimatedStyle} />
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Close />
|
||||
<View className="flex-row justify-center items-center w-full" pointerEvents="box-none">
|
||||
<TakePhoto
|
||||
disabled={!modelLoaded || takingPhoto}
|
||||
takePhoto={takePhoto}
|
||||
showPrediction={showPrediction}
|
||||
/>
|
||||
<CameraFlip flipCamera={flipCamera} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -66,7 +66,7 @@ const AIDebugButton = ( {
|
||||
if ( !isDebug ) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row justify-end pb-[20px]">
|
||||
<View className="flex-row justify-end">
|
||||
<INatIconButton
|
||||
className={classnames(
|
||||
"bg-deeppink",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import classnames from "classnames";
|
||||
import TransparentCircleButton, {
|
||||
CIRCLE_SIZE
|
||||
} from "components/SharedComponents/Buttons/TransparentCircleButton.tsx";
|
||||
import { View } from "components/styledComponents";
|
||||
// eslint-disable-next-line max-len
|
||||
import TransparentCircleButton from "components/SharedComponents/Buttons/TransparentCircleButton.tsx";
|
||||
import React from "react";
|
||||
import { GestureResponderEvent, ViewStyle } from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
@@ -15,21 +13,11 @@ const isTablet = DeviceInfo.isTablet();
|
||||
interface Props {
|
||||
rotatableAnimatedStyle: ViewStyle;
|
||||
toggleFlash: ( _event: GestureResponderEvent ) => void;
|
||||
hasFlash: boolean;
|
||||
hasFlash?: boolean;
|
||||
takePhotoOptions: TakePhotoOptions;
|
||||
flashClassName?: string;
|
||||
}
|
||||
|
||||
// Empty space where a camera button should be so buttons don't jump around
|
||||
// when they appear or disappear
|
||||
const CameraButtonPlaceholder = ( ) => (
|
||||
<View
|
||||
accessibilityElementsHidden
|
||||
aria-hidden
|
||||
className={CIRCLE_SIZE}
|
||||
/>
|
||||
);
|
||||
|
||||
const Flash = ( {
|
||||
rotatableAnimatedStyle,
|
||||
toggleFlash,
|
||||
@@ -39,7 +27,7 @@ const Flash = ( {
|
||||
}: Props ) => {
|
||||
const { t } = useTranslation( );
|
||||
|
||||
if ( !hasFlash ) return <CameraButtonPlaceholder />;
|
||||
if ( !hasFlash ) return null;
|
||||
let testID = "";
|
||||
let accessibilityHint = "";
|
||||
let name = "";
|
||||
@@ -59,9 +47,9 @@ const Flash = ( {
|
||||
<Animated.View
|
||||
style={!isTablet && rotatableAnimatedStyle}
|
||||
className={classnames(
|
||||
flashClassName,
|
||||
"m-0",
|
||||
"border-0"
|
||||
"border-0",
|
||||
flashClassName
|
||||
)}
|
||||
>
|
||||
<TransparentCircleButton
|
||||
|
||||
48
src/components/Camera/Buttons/Gallery.tsx
Normal file
48
src/components/Camera/Buttons/Gallery.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import classnames from "classnames";
|
||||
import { INatIconButton } from "components/SharedComponents";
|
||||
import React from "react";
|
||||
import { ViewStyle } from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
interface Props {
|
||||
rotatableAnimatedStyle: ViewStyle;
|
||||
}
|
||||
|
||||
const isTablet = DeviceInfo.isTablet();
|
||||
|
||||
const Gallery = ( { rotatableAnimatedStyle }: Props ) => {
|
||||
const { t } = useTranslation( );
|
||||
const navigation = useNavigation( );
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={!isTablet && rotatableAnimatedStyle}
|
||||
className="m-0 border-0"
|
||||
>
|
||||
<INatIconButton
|
||||
className={classnames(
|
||||
"bg-black/50",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"border-white",
|
||||
"border-2",
|
||||
"rounded"
|
||||
)}
|
||||
onPress={( ) => navigation.push( "PhotoGallery", { cmonBack: true } )}
|
||||
accessibilityLabel={t( "Photo-importer" )}
|
||||
accessibilityHint={t( "Navigates-to-photo-importer" )}
|
||||
icon="gallery"
|
||||
color={( colors?.white as string ) || "white"}
|
||||
size={26}
|
||||
width={62}
|
||||
height={62}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Gallery;
|
||||
@@ -11,7 +11,7 @@ import { useTranslation } from "sharedHooks";
|
||||
import { getShadowForColor } from "styles/global";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
const DROP_SHADOW = getShadowForColor( colors.darkGray );
|
||||
const DROP_SHADOW = getShadowForColor( colors.black, { offsetHeight: 4 } );
|
||||
|
||||
interface Props {
|
||||
takePhoto: () => Promise<void>;
|
||||
@@ -27,7 +27,7 @@ const TakePhoto = ( {
|
||||
const { t } = useTranslation( );
|
||||
const theme = useTheme( );
|
||||
|
||||
const borderClass = "border-[1.64px] rounded-full h-[60px] w-[60px]";
|
||||
const borderClass = "border-[2px] rounded-full h-[64px] w-[64px]";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -47,19 +47,19 @@ const TakePhoto = ( {
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
disabled={disabled}
|
||||
style={DROP_SHADOW}
|
||||
>
|
||||
{showPrediction
|
||||
? (
|
||||
<View
|
||||
style={DROP_SHADOW}
|
||||
className={classnames(
|
||||
borderClass,
|
||||
"bg-inatGreen items-center justify-center border-darkGray"
|
||||
"bg-inatGreen items-center justify-center border-accessibleGreen"
|
||||
)}
|
||||
>
|
||||
<INatIcon
|
||||
name="sparkly-label"
|
||||
size={24}
|
||||
size={32}
|
||||
color={theme.colors.onPrimary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import classnames from "classnames";
|
||||
import Gallery from "components/Camera/Buttons/Gallery.tsx";
|
||||
import { CloseButton } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import React from "react";
|
||||
@@ -41,7 +42,8 @@ interface Props {
|
||||
flipCamera: ( _event: GestureResponderEvent ) => void;
|
||||
handleCheckmarkPress?: ( _event: GestureResponderEvent ) => void;
|
||||
handleClose?: ( _event: GestureResponderEvent ) => void;
|
||||
hasFlash: boolean;
|
||||
hasFlash?: boolean;
|
||||
hasGalleryButton?: boolean;
|
||||
photosTaken?: boolean;
|
||||
rotatableAnimatedStyle: ViewStyle;
|
||||
showPrediction?: boolean;
|
||||
@@ -54,13 +56,15 @@ interface Props {
|
||||
|
||||
// Empty space where a camera button should be so buttons don't jump around
|
||||
// when they appear or disappear
|
||||
const CameraButtonPlaceholder = ( ) => (
|
||||
const CameraButtonPlaceholder = ( { extraClassName }: { extraClassName?: string } ) => (
|
||||
<View
|
||||
accessibilityElementsHidden
|
||||
aria-hidden
|
||||
className={classnames(
|
||||
// "bg-deeppink",
|
||||
`w-[${CAMERA_BUTTON_DIM}px]`,
|
||||
`h-[${CAMERA_BUTTON_DIM}px]`
|
||||
`h-[${CAMERA_BUTTON_DIM}px]`,
|
||||
extraClassName
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -72,6 +76,7 @@ const TabletButtons = ( {
|
||||
handleCheckmarkPress,
|
||||
handleClose,
|
||||
hasFlash,
|
||||
hasGalleryButton,
|
||||
photosTaken,
|
||||
rotatableAnimatedStyle,
|
||||
showPrediction,
|
||||
@@ -83,33 +88,34 @@ const TabletButtons = ( {
|
||||
}: Props ) => {
|
||||
const tabletCameraOptionsClasses = [
|
||||
"absolute",
|
||||
"h-[380px]",
|
||||
"items-center",
|
||||
"justify-center",
|
||||
"mr-5",
|
||||
"mt-[-190px]",
|
||||
"pb-0",
|
||||
"p-0",
|
||||
"right-0",
|
||||
"top-[50%]"
|
||||
"h-full"
|
||||
];
|
||||
|
||||
return (
|
||||
<View className={classnames( tabletCameraOptionsClasses )}>
|
||||
<View className={classnames( tabletCameraOptionsClasses )} pointerEvents="box-none">
|
||||
{ photosTaken && <CameraButtonPlaceholder extraClassName="mb-[25px]" /> }
|
||||
<Zoom
|
||||
changeZoom={changeZoom}
|
||||
zoomTextValue={zoomTextValue}
|
||||
showZoomButton={showZoomButton}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
zoomClassName="mb-[25px]"
|
||||
/>
|
||||
<Flash
|
||||
toggleFlash={toggleFlash}
|
||||
hasFlash={hasFlash}
|
||||
takePhotoOptions={takePhotoOptions}
|
||||
rotatableAnimatedStyle={rotatableAnimatedStyle}
|
||||
flashClassName="m-0 mb-[25px]"
|
||||
/>
|
||||
<CameraFlip
|
||||
flipCamera={flipCamera}
|
||||
cameraFlipClasses="m-0 mt-[25px]"
|
||||
cameraFlipClasses="m-0"
|
||||
/>
|
||||
<View className="mt-[40px] mb-[40px]">
|
||||
<TakePhoto
|
||||
@@ -121,7 +127,7 @@ const TabletButtons = ( {
|
||||
{ photosTaken && (
|
||||
<Animated.View
|
||||
style={!isTablet && rotatableAnimatedStyle}
|
||||
className={classnames( checkmarkClasses, "mb-[25px]" )}
|
||||
className={classnames( checkmarkClasses )}
|
||||
>
|
||||
<GreenCheckmark
|
||||
handleCheckmarkPress={handleCheckmarkPress || ( () => null )}
|
||||
@@ -131,7 +137,7 @@ const TabletButtons = ( {
|
||||
<View
|
||||
className={classnames(
|
||||
cameraOptionsClasses,
|
||||
{ "mb-[25px]": !photosTaken }
|
||||
{ "mt-[25px]": photosTaken }
|
||||
)}
|
||||
>
|
||||
<CloseButton
|
||||
@@ -139,7 +145,13 @@ const TabletButtons = ( {
|
||||
size={18}
|
||||
/>
|
||||
</View>
|
||||
{ !photosTaken && <CameraButtonPlaceholder /> }
|
||||
{ hasFlash && <CameraButtonPlaceholder extraClassName="mt-[25px]" /> }
|
||||
{ showZoomButton && <CameraButtonPlaceholder extraClassName="mt-[25px]" /> }
|
||||
{ hasGalleryButton && (
|
||||
<View className="absolute bottom-6">
|
||||
<Gallery rotatableAnimatedStyle={rotatableAnimatedStyle} />
|
||||
</View>
|
||||
) }
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,6 +123,8 @@ const PhotoGallery = ( ): Node => {
|
||||
// Determine if we need to go back to ObsList or ObsDetails screen
|
||||
} else if ( params && params.previousScreen && params.previousScreen.name === "ObsDetails" ) {
|
||||
navigateToObsDetails( navigation, params.previousScreen.params.uuid );
|
||||
} else if ( params?.cmonBack && navigation.canGoBack() ) {
|
||||
navigation.goBack();
|
||||
} else {
|
||||
navToObsList();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user