Photo similarity representative photo result sent to taxon details (#2699)

* A key like this does not exist because we don't save rep Photo to realm

* Show rep photo on suggestions

* Add comment

* Update types.d.ts

* Move code around

* Send rep photo ID to taxon result

* Send rep photo to taxon result

* Add white background to box on taxon details

Closes MOB-491

* Comments

* Only send ID around and move photo to top

* Send first photo id also from match screen

* If rep photo is of same taxon, only send ID, else send entire photo to taxon details

* Refactor taxon image

* Updated realm types

* If rep photo is of other taxon send entire object

* Create copy of realm taxon

Avoids an error "object has been invalidated and deleted" which I didn't have tome to track down.
This commit is contained in:
Johannes Klein
2025-02-27 15:20:06 +01:00
committed by GitHub
parent e2b8adbfa0
commit 360a63cdbe
7 changed files with 102 additions and 40 deletions

1
src/api/types.d.ts vendored
View File

@@ -83,6 +83,7 @@ export interface ApiObservationSound {
export interface ApiTaxon {
default_photo?: ApiPhoto;
representative_photo?: ApiPhoto;
iconic_taxon_name?: string;
id?: number;
name?: string;

View File

@@ -52,8 +52,7 @@ const SuggestionsResult = ( {
// and is currently not added to the taxon realm. So, if it is available directly from the
// suggestion, i.e. taxonProp, use it. Otherwise, use the default photo from the taxon.
const taxonImage = {
uri: taxonProp?.representativePhoto?.url
|| taxonProp?.representative_photo?.url
uri: taxonProp?.representative_photo?.url
|| usableTaxon?.default_photo?.url
|| usableTaxon?.defaultPhoto?.url
};

View File

@@ -254,8 +254,14 @@ const MatchContainer = ( ) => {
const otherSuggestions = orderedSuggestions
.filter( suggestion => suggestion.taxon.id !== taxonId );
const navToTaxonDetails = ( ) => {
navigation.push( "TaxonDetails", { id: taxonId } );
const navToTaxonDetails = photo => {
const params = { id: taxonId };
if ( !photo?.isRepresentativeButOtherTaxon ) {
params.firstPhotoID = photo.id;
} else {
params.representativePhoto = photo;
}
navigation.push( "TaxonDetails", params );
};
const handleSaveOrDiscardPress = async action => {

View File

@@ -6,7 +6,7 @@ import {
import {
Image, Pressable, View
} from "components/styledComponents";
import _, { compact, uniqBy } from "lodash";
import _, { compact } from "lodash";
import React, { useEffect, useState } from "react";
import Photo from "realmModels/Photo";
import getImageDimensions from "sharedHelpers/getImageDimensions";
@@ -16,7 +16,7 @@ type Props = {
representativePhoto: Object,
taxon: Object,
obsPhotos: Array<Object>,
navToTaxonDetails: ( ) => void
navToTaxonDetails: ( photo: Object ) => void
}
const PhotosSection = ( {
@@ -35,22 +35,33 @@ const PhotosSection = ( {
const taxonPhotos = compact(
localTaxonPhotos
? localTaxonPhotos.map( taxonPhoto => taxonPhoto.photo )
? localTaxonPhotos.map( taxonPhoto => ( { ...taxonPhoto.photo } ) )
: [taxon?.defaultPhoto]
);
// don't show the iconic taxon photo which is a mashup of 9 photos
const taxonPhotosNoIconic = localTaxon?.isIconic
? taxonPhotos.slice( 1, 4 )
: taxonPhotos.slice( 0, 3 );
// Add the representative photo at the start of the list of taxon photos.
const taxonPhotosWithRepPhoto = compact( [representativePhoto, ...taxonPhotosNoIconic] );
// The representative photo might be already included in taxonPhotosNoIconic
const uniqueTaxonPhotos = uniqBy( taxonPhotosWithRepPhoto, "id" );
if ( uniqueTaxonPhotos.length > 3 ) {
uniqueTaxonPhotos.pop( );
// don't show the iconic taxon photo which is a mashup of 9 bestTaxonPhotos
if ( localTaxon?.isIconic ) {
taxonPhotos.splice( 0 );
}
// If the representative photo is already included in taxonPhotos, don't add it but move
// it to the start of the list.
let firstPhoto;
if ( representativePhoto && taxonPhotos.some( photo => photo.id === representativePhoto.id ) ) {
const repPhotoIndex = taxonPhotos.findIndex( photo => photo.id === representativePhoto.id );
// The first photo to show is the realm version of the representative photo
firstPhoto = taxonPhotos.splice( repPhotoIndex, 1 )[0];
} else if ( representativePhoto ) {
// This is possible because a representative photo can be from a different taxon, e.g. children
// of common ancestors. In this case, the representative photo is not included in taxonPhotos.
firstPhoto = { ...representativePhoto, isRepresentativeButOtherTaxon: true };
}
// Add the representative photo at the start of the list of taxon bestTaxonPhotos.
const taxonPhotosWithRepPhoto = compact( [
firstPhoto,
...taxonPhotos
] );
const bestTaxonPhotos = taxonPhotosWithRepPhoto.slice( 0, 3 );
const observationPhotos = compact(
obsPhotos
? obsPhotos.map( obsPhoto => obsPhoto.photo )
@@ -70,24 +81,24 @@ const PhotosSection = ( {
}, [observationPhoto] );
const getLayoutClasses = ( ) => {
// Basic layout: no taxon photos + obs photo a square
// Basic layout: no taxon bestTaxonPhotos + obs photo a square
let containerClass = "flex-row";
let observationPhotoClass = "w-full h-full";
let taxonPhotosContainerClass;
let taxonPhotoClass;
// If there is only one taxon photo: obs photo a square,
// taxon photo a square in the lower right corner of the obs photo
if ( uniqueTaxonPhotos.length === 1 ) {
if ( bestTaxonPhotos.length === 1 ) {
containerClass = "flex-row relative";
observationPhotoClass = "w-full h-full";
taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3";
taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white";
}
if ( uniqueTaxonPhotos.length > 1 ) {
if ( bestTaxonPhotos.length > 1 ) {
if ( displayPortraitLayout ) {
containerClass = "flex-row";
observationPhotoClass = "w-2/3 h-full pr-[3px]";
if ( uniqueTaxonPhotos.length === 2 ) {
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]";
taxonPhotoClass = "w-full h-1/2";
} else {
@@ -97,7 +108,7 @@ const PhotosSection = ( {
} else {
containerClass = "flex-col";
observationPhotoClass = "w-full h-2/3 pb-[3px]";
if ( uniqueTaxonPhotos.length === 2 ) {
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]";
taxonPhotoClass = "w-1/2 h-full";
} else {
@@ -147,10 +158,10 @@ const PhotosSection = ( {
layoutClasses?.taxonPhotosContainerClass
)}
>
{uniqueTaxonPhotos.map( photo => (
{bestTaxonPhotos.map( photo => (
<Pressable
accessibilityRole="button"
onPress={navToTaxonDetails}
onPress={() => navToTaxonDetails( photo )}
accessibilityState={{ disabled: false }}
key={photo.id}
className={classnames(
@@ -180,12 +191,12 @@ const PhotosSection = ( {
return (
<View className={classnames( "h-[390px]", layoutClasses.containerClass )}>
{renderObservationPhoto( )}
{uniqueTaxonPhotos.length > 0 && renderTaxonPhotos( )}
{bestTaxonPhotos.length > 0 && renderTaxonPhotos( )}
<MediaViewerModal
showModal={mediaViewerVisible}
onClose={( ) => setMediaViewerVisible( false )}
uri={observationPhoto}
photos={observationPhotos}
bestTaxonPhotos={observationPhotos}
/>
</View>
);

View File

@@ -9,7 +9,7 @@ import {
import { Pressable, View } from "components/styledComponents";
import React, { PropsWithChildren } from "react";
import type { GestureResponderEvent } from "react-native";
import type { RealmTaxon } from "realmModels/types";
import type { RealmTaxon, RealmTaxonPhoto } from "realmModels/types";
import { accessibleTaxonName } from "sharedHelpers/taxon";
import { useCurrentUser, useTaxon, useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
@@ -89,19 +89,46 @@ const TaxonResult = ( {
? localTaxon
: taxonProp;
const accessibleName = accessibleTaxonName( usableTaxon, currentUser, t );
// A representative photo is dependant on the actual image that was scored by computer vision
// and is currently not added to the taxon realm. So, if it is available directly from the
// suggestion, i.e. taxonProp, use it. Otherwise, use the default photo from the taxon.
const representativePhoto = ( taxonProp as ApiTaxon )?.representative_photo;
// I have seen the RealmTaxon that is accessed here get invalidated and deleted
// while this screen is still in stack and therefore the app erroring out.
// Have not had time to investigate further, but this is a workaround for now.
const taxonImagePointer = representativePhoto
|| ( usableTaxon as ApiTaxon )?.default_photo
|| ( usableTaxon as RealmTaxon )?.defaultPhoto;
const taxonImage = React.useMemo( () => ( { ...taxonImagePointer } ), [taxonImagePointer] );
const taxonImageSource = { uri: taxonImage?.url };
const isRepresentativeButOtherTaxon = representativePhoto
&& !localTaxon?.taxonPhotos?.some(
( tp: RealmTaxonPhoto ) => tp.photo.id === representativePhoto.id
);
const navToTaxonDetails = React.useCallback( ( ) => {
navigation.push( "TaxonDetails", {
const params = {
id: usableTaxon?.id,
hideNavButtons,
lastScreen,
vision
} );
};
if ( !isRepresentativeButOtherTaxon ) {
params.firstPhotoID = taxonImage?.id;
} else {
params.representativePhoto = taxonImage;
}
navigation.push( "TaxonDetails", params );
}, [
hideNavButtons,
lastScreen,
navigation,
usableTaxon?.id,
vision
vision,
taxonImage,
isRepresentativeButOtherTaxon
] );
const TaxonResultMain = React.useCallback( ( props: TaxonResultMainProps ) => (
unpressable
@@ -131,11 +158,6 @@ const TaxonResult = ( {
// useTaxon could return null, and it's at least remotely possible taxonProp is null
if ( !usableTaxon ) return null;
const taxonImage = {
uri: ( usableTaxon as ApiTaxon )?.default_photo?.url
|| ( usableTaxon as RealmTaxon )?.defaultPhoto?.url
};
const renderCheckmark = () => {
if ( checkmarkFocused ) {
return (
@@ -191,7 +213,7 @@ const TaxonResult = ( {
<View className="w-[62px] h-[62px] justify-center relative">
<ObsImagePreview
// TODO fix when ObsImagePreview typed
source={taxonImage}
source={taxonImageSource}
testID={`${testID}.photo`}
iconicTaxonName={usableTaxon?.iconic_taxon_name}
className="rounded-xl"

View File

@@ -88,7 +88,9 @@ const TaxonDetails = ( ): Node => {
// Hooks
const navigation = useNavigation( );
const { params } = useRoute( );
const { id, hideNavButtons } = params;
const {
id, hideNavButtons, firstPhotoID, representativePhoto
} = params;
const { t } = useTranslation( );
const { isConnected } = useNetInfo( );
const { remoteUser } = useUserMe( );
@@ -193,6 +195,18 @@ const TaxonDetails = ( ): Node => {
? taxon.taxonPhotos.map( taxonPhoto => taxonPhoto.photo )
: [taxon?.defaultPhoto]
);
// Move the first photo to top if it was passed in as a prop
if ( firstPhotoID ) {
const firstPhotoIndex = photos.findIndex( photo => photo.id === firstPhotoID );
if ( firstPhotoIndex > 0 ) {
const firstPhoto = photos.splice( firstPhotoIndex, 1 );
photos.unshift( firstPhoto[0] );
}
}
// Add the representative photo to the top of the list
if ( representativePhoto ) {
photos.unshift( representativePhoto );
}
const updateTaxon = useCallback( ( ) => {
updateObservationKeys( {
@@ -450,7 +464,7 @@ const TaxonDetails = ( ): Node => {
/>
)}
{showSelectButton && (
<ButtonBar containerClass="items-center z-50">
<ButtonBar containerClass="items-center z-50 bg-white">
<Button
testID="TaxonDetails.SelectButton"
className="max-w-[500px] w-full"

View File

@@ -42,9 +42,17 @@ export interface RealmObservationSound extends RealmObject {
wasSynced: ( ) => boolean;
}
export interface RealmTaxonPhoto extends RealmObject {
_created_at?: Date;
_synced_at?: Date;
_updated_at?: Date;
id: number;
photo: RealmPhoto;
}
export interface RealmTaxon extends RealmObject {
id: number;
defaultPhoto?: RealmPhoto,
defaultPhoto?: RealmPhoto;
name?: string;
preferredCommonName?: string;
rank?: string;
@@ -53,6 +61,7 @@ export interface RealmTaxon extends RealmObject {
iconic_taxon_name?: string;
ancestor_ids?: number[];
_synced_at?: Date;
taxonPhotos?: RealmTaxonPhoto[];
}
export interface RealmObservationPojo {