From e39371e56a1ee24ff52c365a93b941cabad9093a Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Mon, 9 Mar 2026 07:19:42 -1000 Subject: [PATCH 01/10] Clean up plant-photos api endpoint --- apps/api/src/index.ts | 6 ++-- apps/api/src/routers/media/index.ts | 10 +++++++ apps/mobile/src/app/(tabs)/plants.tsx | 6 ++-- apps/mobile/src/app/plants/[id].tsx | 4 +-- apps/mobile/src/app/plants/add-edit.tsx | 8 ++---- apps/mobile/src/utils/plantPhoto.ts | 38 ++++++++++++++++++------- 6 files changed, 47 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/routers/media/index.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 05d8d93..992d3f2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,11 +9,11 @@ import { mongo } from './db' import { index } from './endpoints/index' import { getHealth } from './endpoints/health/get' -import { getPlantPhoto } from './endpoints/plantPhoto/get' import { debugEndpoints } from './middlewares/debugEndpoints' import { cronRouter } from './routers/cron' +import { mediaRouter } from './routers/media' import { trpcRouter } from './routers/trpc' import { isTest } from './utils/isTest' @@ -42,8 +42,8 @@ app.use('/health', getHealth) // Cronjob endpoints app.use('/cron', cronRouter) -// Plant photo proxy (private blob access; requires Authorization: Bearer ; use ?plantId=... or ?url=...) -app.get('/api/plant-photo', getPlantPhoto) +// Media endpoints +app.use('/media', mediaRouter) // tRPC endpoints app.use( diff --git a/apps/api/src/routers/media/index.ts b/apps/api/src/routers/media/index.ts new file mode 100644 index 0000000..991260b --- /dev/null +++ b/apps/api/src/routers/media/index.ts @@ -0,0 +1,10 @@ +import { Router } from 'express' + +import { getPlantPhoto } from '../../endpoints/plantPhoto/get' + +const mediaRouter = Router() + +mediaRouter + .get('/plant-photos', getPlantPhoto) + +export { mediaRouter } diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index 7defcba..e8ce3cc 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -23,14 +23,12 @@ import { trpc } from '../../trpc' import { getLifecycleLabelWithIcon } from '../../utils/lifecycle' import { getPlantPhotoImageSource } from '../../utils/plantPhoto' -import { config } from '../../config' import { palette, styles } from '../../styles' export function PlantsScreen() { const router = useRouter() const { alert } = useAlert() const { token } = useAuth() - const apiBaseUrl = config.api.baseUrl const { expandPlantId } = useLocalSearchParams<{ expandPlantId?: string }>() const scrollViewRef = React.useRef(null) @@ -220,9 +218,9 @@ export function PlantsScreen() { > - {getPlantPhotoImageSource({ plant }, { apiBaseUrl, token }) ? ( + {getPlantPhotoImageSource({ plant }, { token }) ? ( diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx index c9d13d4..b504829 100644 --- a/apps/mobile/src/app/plants/[id].tsx +++ b/apps/mobile/src/app/plants/[id].tsx @@ -25,7 +25,6 @@ import { trpc } from '../../trpc' import { getLifecycleLabelWithIcon, getLifecycleIcon, type PlantLifecycle } from '../../utils/lifecycle' import { getPlantPhotoImageSource } from '../../utils/plantPhoto' -import { config } from '../../config' import { palette, styles } from '../../styles' export default function PlantDetailScreen() { @@ -34,7 +33,6 @@ export default function PlantDetailScreen() { const navigation = useNavigation() const insets = useSafeAreaInsets() const { token } = useAuth() - const apiBaseUrl = config.api.baseUrl const [showChat, setShowChat] = React.useState(false) const [showChoreForm, setShowChoreForm] = React.useState(false) @@ -122,7 +120,7 @@ export default function PlantDetailScreen() { }) } - const headerImageSource = plant ? getPlantPhotoImageSource({ plant }, { apiBaseUrl, token }) : null + const headerImageSource = plant ? getPlantPhotoImageSource({ plant }, { token }) : null return ( <> diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 55ded43..7cbbc6c 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -29,7 +29,6 @@ import { trpc } from '../../trpc' import { getLifecycleLabelWithIcon, LIFECYCLE_ICONS, type PlantLifecycle } from '../../utils/lifecycle' import { getPlantPhotoImageSource } from '../../utils/plantPhoto' -import { config } from '../../config' import { palette, styles } from '../../styles' type FormData = { @@ -60,7 +59,6 @@ export function AddEditPlantScreen() { const { alert } = useAlert() const insets = useSafeAreaInsets() const { token } = useAuth() - const apiBaseUrl = config.api.baseUrl const [formData, setFormData] = useState(initialFormData) const [showNameInput, setShowNameInput] = useState(false) @@ -391,7 +389,7 @@ export function AddEditPlantScreen() { return ( @@ -468,9 +466,9 @@ export function AddEditPlantScreen() { borderStyle: 'dashed', }} > - {getPlantPhotoSrc && getPlantPhotoImageSource(getPlantPhotoSrc, { apiBaseUrl, token }) && ( + {getPlantPhotoSrc && getPlantPhotoImageSource(getPlantPhotoSrc, { token }) && ( diff --git a/apps/mobile/src/utils/plantPhoto.ts b/apps/mobile/src/utils/plantPhoto.ts index 9248e9a..04d40d6 100644 --- a/apps/mobile/src/utils/plantPhoto.ts +++ b/apps/mobile/src/utils/plantPhoto.ts @@ -1,9 +1,10 @@ +import { config } from '../config' + /** * Private Vercel Blob URLs require auth; we proxy them through our API. */ const PRIVATE_BLOB_HOST = 'blob.vercel-storage.com' - /** * Get image source for a plant photo. * Pass { plant } for a plant with an existing image. @@ -19,29 +20,46 @@ export function getPlantPhotoImageSource( photoUrl?: string, plant?: { _id: string, photoUrl?: string | null, species?: { imageUrl?: string | null } | null, updatedAt?: Date | string }, }, - options: { apiBaseUrl: string | undefined, token: string | null }, + options: { token: string | null }, ): { uri: string, headers?: { Authorization: string } } | null { - const photoUrl = photoUrlProp ?? plant?.photoUrl ?? plant?.species?.imageUrl ?? null + const plantPhotoUrl = photoUrlProp ?? plant?.photoUrl ?? null + const speciesPhotoUrl = plant?.species?.imageUrl ?? null + + if (!plantPhotoUrl && !speciesPhotoUrl) return null + + const { token } = options + + if (plantPhotoUrl) { + if (!isPrivateBlobUrl(plantPhotoUrl)) { + return { uri: plantPhotoUrl } + } - if (!photoUrl) return null + if (!token) { + throw new Error('Token is required') + } - const { apiBaseUrl, token } = options + const apiBaseUrl = config.api.baseUrl + if (!apiBaseUrl) { + throw new Error('API base URL is required') + } - if (isPrivateBlobUrl(photoUrl) && apiBaseUrl && token) { - const plantId = plant ? `plantId=${encodeURIComponent(plant._id)}` : `url=${encodeURIComponent(photoUrl)}` const updatedAt = plant?.updatedAt const cacheBuster = updatedAt != null ? `&_v=${typeof updatedAt === 'string' ? updatedAt : new Date(updatedAt).getTime()}` : '' - const queryString = `${plantId}${cacheBuster}` + const queryString = `${plant ? `plantId=${encodeURIComponent(plant._id)}` : `url=${encodeURIComponent(plantPhotoUrl)}`}${cacheBuster}` return { - uri: `${apiBaseUrl.replace(/\/$/, '')}/api/plant-photo?${queryString}`, + uri: `${apiBaseUrl.replace(/\/$/, '')}/media/plant-photos?${queryString}`, headers: { Authorization: `Bearer ${token}` }, } } - return { uri: photoUrl } + if (speciesPhotoUrl) { + return { uri: speciesPhotoUrl } + } + + return null } function isPrivateBlobUrl(url: string | null | undefined): boolean { From 4de4b398db79e721cda412fccac02001cd6d5029 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Mon, 9 Mar 2026 07:22:18 -1000 Subject: [PATCH 02/10] Clean up plant-photos api endpoint --- .../src/endpoints/{plantPhoto => media/plantPhotos}/get.ts | 4 ++-- apps/api/src/routers/media/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/api/src/endpoints/{plantPhoto => media/plantPhotos}/get.ts (97%) diff --git a/apps/api/src/endpoints/plantPhoto/get.ts b/apps/api/src/endpoints/media/plantPhotos/get.ts similarity index 97% rename from apps/api/src/endpoints/plantPhoto/get.ts rename to apps/api/src/endpoints/media/plantPhotos/get.ts index a470073..40c0e0a 100644 --- a/apps/api/src/endpoints/plantPhoto/get.ts +++ b/apps/api/src/endpoints/media/plantPhotos/get.ts @@ -4,8 +4,8 @@ import { Readable } from 'stream' import { get as getBlob } from '@vercel/blob' -import { config } from '../../config' -import { Plant } from '../../models' +import { config } from '../../../config' +import { Plant } from '../../../models' /** * Stream a private plant photo blob. Requires exactly one of: diff --git a/apps/api/src/routers/media/index.ts b/apps/api/src/routers/media/index.ts index 991260b..dc3f0e0 100644 --- a/apps/api/src/routers/media/index.ts +++ b/apps/api/src/routers/media/index.ts @@ -1,6 +1,6 @@ import { Router } from 'express' -import { getPlantPhoto } from '../../endpoints/plantPhoto/get' +import { getPlantPhoto } from '../../endpoints/media/plantPhotos/get' const mediaRouter = Router() From 5f2e9237298409f19229a46090ee5bec3c0d4182 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Mon, 9 Mar 2026 15:07:30 -1000 Subject: [PATCH 03/10] Enhance species identification UI. Re #197 --- .../src/services/plantIdentification/index.ts | 46 ++- .../plantIdentification/providers/plantNet.ts | 43 +- apps/mobile/src/app/(tabs)/fertilizers.tsx | 1 - apps/mobile/src/app/(tabs)/index.tsx | 1 - apps/mobile/src/app/(tabs)/plants.tsx | 1 - apps/mobile/src/app/plants/add-edit.tsx | 387 +++++++++++++++++- 6 files changed, 437 insertions(+), 42 deletions(-) diff --git a/apps/api/src/services/plantIdentification/index.ts b/apps/api/src/services/plantIdentification/index.ts index 8111111..6a488b4 100644 --- a/apps/api/src/services/plantIdentification/index.ts +++ b/apps/api/src/services/plantIdentification/index.ts @@ -1,33 +1,51 @@ import * as plantNetProvider from './providers/plantNet' export type PlantIdentificationResult = { - confidence: number, + id: string | null, // Generated ID that we derive from other fields commonNames: string[], - scientificName: string, - scientificNameAuthorship: string, + confidence: number, genus: string, genusAuthorship: string, family: string, familyAuthorship: string, + images: PlantIdentificationImage[], + scientificName: string, + scientificNameAuthorship: string, +} + +export type PlantIdentificationImage = { + organ?: string, + url: string, } -const MIN_CONFIDENCE = 0.1 +const MIN_CONFIDENCE = 0.0 export const identifyPlantByImages = async (images: Buffer[]): Promise => { const plantNetResult = await plantNetProvider.identifyPlantByImages(images) const plantIdentificationResult = plantNetResult .filter(result => result.score > MIN_CONFIDENCE) - .map(result => ({ - confidence: result.score, - commonNames: result.species.commonNames, - scientificName: result.species.scientificNameWithoutAuthor, - scientificNameAuthorship: result.species.scientificNameAuthorship, - genus: result.species.genus.scientificNameWithoutAuthor, - genusAuthorship: result.species.genus.scientificNameAuthorship, - family: result.species.family.scientificNameWithoutAuthor, - familyAuthorship: result.species.family.scientificNameAuthorship, - })) + .map(result => { + const images: PlantIdentificationImage[] = (result.images || []) + .map(img => ({ + url: img.url?.m || img.url?.s || img.url?.o, + ...(img.organ && { organ: img.organ }), + })) + .filter((item): item is PlantIdentificationImage => !!item.url) + + return { + id: result.powo?.id || null, + commonNames: result.species.commonNames, + confidence: result.score, + genus: result.species.genus.scientificNameWithoutAuthor, + genusAuthorship: result.species.genus.scientificNameAuthorship, + family: result.species.family.scientificNameWithoutAuthor, + familyAuthorship: result.species.family.scientificNameAuthorship, + images, + scientificName: result.species.scientificNameWithoutAuthor, + scientificNameAuthorship: result.species.scientificNameAuthorship, + } + }) return plantIdentificationResult } diff --git a/apps/api/src/services/plantIdentification/providers/plantNet.ts b/apps/api/src/services/plantIdentification/providers/plantNet.ts index 092777a..3aaad1f 100644 --- a/apps/api/src/services/plantIdentification/providers/plantNet.ts +++ b/apps/api/src/services/plantIdentification/providers/plantNet.ts @@ -8,33 +8,46 @@ import * as debugService from '../../debug' const debugPlantIdentification = debugService.init('app:plantIdentification') const plantNetIdentificationResultSchema = z.object({ + gbif: z.object({ id: z.string() }).optional(), // Global Biodiversity Information Facility id (see powo below) + iucn: z.object({ id: z.string(), category: z.string().optional() }).optional(), // International Union for Conservation of Nature id (see powo below) + images: z.array( + z.object({ + organ: z.string().optional(), + url: z.object({ + o: z.string(), // original, largest + m: z.string().optional(), // medium + s: z.string().optional(), // small + }), + }) + ).optional(), + powo: z.object({ id: z.string() }).optional(), // Plants of the World Online id - this is the preferred and primary source score: z.number().min(0).max(1), species: z.object({ - scientificName: z.string(), - scientificNameWithoutAuthor: z.string(), - scientificNameAuthorship: z.string(), - genus: z.object({ - scientificNameWithoutAuthor: z.string(), - scientificNameAuthorship: z.string(), - }), + commonNames: z.array(z.string()), family: z.object({ + scientificNameAuthorship: z.string(), scientificNameWithoutAuthor: z.string(), + }), + genus: z.object({ scientificNameAuthorship: z.string(), + scientificNameWithoutAuthor: z.string(), }), - commonNames: z.array(z.string()), + scientificName: z.string(), + scientificNameAuthorship: z.string(), + scientificNameWithoutAuthor: z.string(), }), }) const plantNetResponseSchema = z.object({ + language: z.string(), + preferedReferential: z.string(), query: z.object({ project: z.string(), images: z.array(z.string()), organs: z.array(z.string()), }), - language: z.string(), - preferedReferential: z.string(), - results: z.array(plantNetIdentificationResultSchema), remainingIdentificationRequests: z.number(), + results: z.array(plantNetIdentificationResultSchema), }) export type PlantNetIdentificationResult = z.infer @@ -64,7 +77,7 @@ export const identifyPlantByImages = async (images: Buffer[]): Promise setFilterModalVisible(true)} - color={palette.brandPrimary} isPulsating={!!searchQuery.trim()} testID='fertilizers-filter-button' /> diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 4bbd553..5f8e358 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -377,7 +377,6 @@ function ToDoScreen() { buttons={ setFilterModalVisible(true)} - color={palette.brandPrimary} isPulsating={!!searchQuery.trim()} testID='todolist-filter-button' /> diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index e8ce3cc..a5b4a29 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -155,7 +155,6 @@ export function PlantsScreen() { buttons={ setFilterModalVisible(true)} - color={palette.brandPrimary} isPulsating={!!searchQuery.trim()} testID='plants-filter-button' /> diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 7cbbc6c..69cd327 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -1,14 +1,14 @@ -import React, { useState, useEffect } from 'react' -import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import React, { useEffect, useRef, useState } from 'react' +import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native' import * as Device from 'expo-device' import * as ImagePicker from 'expo-image-picker' import { Ionicons } from '@expo/vector-icons' - import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { ISpecies } from '@plannting/api/dist/models/Species' +import type { PlantIdentificationImage } from '@plannting/api/dist/services/plantIdentification' import { DateTimePicker } from '../../components/DateTimePicker' import { FertilizerRecommendations } from '../../components/FertilizerRecommendations' @@ -16,12 +16,14 @@ import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSk import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SegmentedControl } from '../../components/SegmentedControl' +import { ShimmerText } from '../../components/ShimmerText' import { SpeciesCard } from '../../components/SpeciesCard' import { TextInput } from '../../components/TextInput' import { useAlert } from '../../contexts/AlertContext' import { useAuth } from '../../contexts/AuthContext' +import { useDebounce } from '../../hooks/useDebounce' import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' import { trpc } from '../../trpc' @@ -74,6 +76,15 @@ export function AddEditPlantScreen() { const [showPlantedAtDatePicker, setShowPlantedAtDatePicker] = useState(false) const [showLifecycleChangeModal, setShowLifecycleChangeModal] = useState(false) const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = useState(false) + const [showIdentificationModal, setShowIdentificationModal] = useState(false) + const [fullScreenImages, setFullScreenImages] = useState(null) + const [fullScreenImageIndex, setFullScreenImageIndex] = useState(0) + const [identificationCheckedIndex, setIdentificationCheckedIndex] = useState(null) + const [modalManualSearchQuery, setModalManualSearchQuery] = useState('') + const [modalManualSearchSelectedSpecies, setModalManualSearchSelectedSpecies] = useState(null) + const { width: windowWidth } = useWindowDimensions() + const identificationModalScrollRef = useRef(null) + const manualSearchSectionYRef = useRef(0) useEffect(() => { if (mode !== 'add') { @@ -109,10 +120,34 @@ export function AddEditPlantScreen() { { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } ) + const debouncedModalManualSearchQuery = useDebounce(modalManualSearchQuery.trim(), 300) + + const { data: modalSpeciesData, isFetching: isFetchingModalSpecies } = trpc.species.list.useQuery( + { q: debouncedModalManualSearchQuery || undefined }, + { enabled: showIdentificationModal && identificationCheckedIndex === 'manual' && debouncedModalManualSearchQuery.length > 2 } + ) + useRefreshOnFocus(() => { refetch() }) + // When manual species results appear, scroll so the list is visible + const prevManualResultsLengthRef = useRef(0) + useEffect(() => { + const len = modalSpeciesData?.species?.length ?? 0 + if (identificationCheckedIndex === 'manual' && len > 0 && prevManualResultsLengthRef.current === 0) { + const t = setTimeout(() => { + identificationModalScrollRef.current?.scrollTo({ + y: manualSearchSectionYRef.current + 120, + animated: true, + }) + }, 300) + prevManualResultsLengthRef.current = len + return () => clearTimeout(t) + } + prevManualResultsLengthRef.current = len + }, [identificationCheckedIndex, modalSpeciesData?.species?.length]) + const plant = data?.plants?.find(p => p._id === id) /** Saved photo URL from API (list response includes photoUrl at runtime). */ @@ -208,14 +243,7 @@ export function AddEditPlantScreen() { const { isPending: isLoadingIdentification, ...identifyByImagesMutation - } = trpc.plants.identifyByImages.useMutation({ - onSuccess: (results) => { - if (results.length > 0) { - setSpeciesSearchQuery(results[0].scientificName) - setShowSpeciesSuggestions(true) - } - }, - }) + } = trpc.plants.identifyByImages.useMutation() const handleSpeciesSelect = (species: NonNullable['species'][0]) => { // Update speciesId and set name to commonName if name is empty @@ -310,6 +338,7 @@ export function AddEditPlantScreen() { setIdentificationPhotoUri(asset.uri) setIdentificationPhotoBase64(asset.base64 ?? null) if (asset.base64 && !selectedSpecies) { + setShowIdentificationModal(true) identifyByImagesMutation.mutate({ images: [asset.base64] }) } } @@ -749,6 +778,342 @@ export function AddEditPlantScreen() { + + { + setShowIdentificationModal(false) + setFullScreenImages(null) + setIdentificationCheckedIndex(null) + setModalManualSearchQuery('') + setModalManualSearchSelectedSpecies(null) + }} + > + + {fullScreenImages && fullScreenImages.length > 0 ? ( + + { + const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth) + setFullScreenImageIndex(index) + }} + style={{ flex: 1 }} + contentContainerStyle={{ flexGrow: 1 }} + > + {fullScreenImages.map((img, index) => ( + setFullScreenImages(null)} + > + + + ))} + + + + {fullScreenImages[fullScreenImageIndex]?.organ ?? ''} + + {fullScreenImages.length > 1 && ( + + {fullScreenImageIndex + 1} / {fullScreenImages.length} + + )} + + setFullScreenImages(null)} + style={{ + position: 'absolute', + top: 60, + left: 20, + padding: 8, + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 22, + }} + > + ← Back + + + ) : ( + <> + + { setShowIdentificationModal(false); setFullScreenImages(null); setIdentificationCheckedIndex(null); setModalManualSearchQuery(''); setModalManualSearchSelectedSpecies(null) }}> + + + + Plant Identification + + { + if (identificationCheckedIndex === null) return + if (identificationCheckedIndex === 'manual') { + if (modalManualSearchSelectedSpecies) { + handleSpeciesSelect(modalManualSearchSelectedSpecies as NonNullable['species'][0]) + setShowIdentificationModal(false) + setFullScreenImages(null) + setIdentificationCheckedIndex(null) + setModalManualSearchQuery('') + setModalManualSearchSelectedSpecies(null) + } + return + } + const result = identifyByImagesMutation.data?.[identificationCheckedIndex] + if (result) { + setSpeciesSearchQuery(result.scientificName) + setShowSpeciesSuggestions(true) + setShowIdentificationModal(false) + setFullScreenImages(null) + setIdentificationCheckedIndex(null) + } + }} + disabled={ + identificationCheckedIndex === null || + (identificationCheckedIndex === 'manual' && !modalManualSearchSelectedSpecies) + } + style={{ padding: 8 }} + > + + + + + + {isLoadingIdentification ? ( + + + Thinking... + + ) : ( + + {identifyByImagesMutation.data && identifyByImagesMutation.data.length === 0 && ( + + 🤷 + Dang! + No results found. Try taking a clearer photo or search below. + + )} + {identifyByImagesMutation.isError && ( + + An error occurred during identification. Try again or search below. + + )} + {(identifyByImagesMutation.data ?? []).map((result, index) => ( + setIdentificationCheckedIndex(index)} + activeOpacity={0.7} + > + {result.images?.[0]?.url ? ( + { + setFullScreenImageIndex(0) + setFullScreenImages(result.images ?? []) + }} + style={{ marginRight: 16 }} + activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + ) : ( + + )} + + + {result.commonNames && result.commonNames.length > 0 + ? result.commonNames[0] + : result.scientificName} + + {result.commonNames && result.commonNames.length > 0 && ( + + {result.scientificName} + + )} + + Confidence: {(result.confidence * 100).toFixed(1)}% + + + + + ))} + { manualSearchSectionYRef.current = e.nativeEvent.layout.y }} + > + { + setIdentificationCheckedIndex('manual') + setTimeout(() => { + identificationModalScrollRef.current?.scrollTo({ + y: manualSearchSectionYRef.current, + animated: true, + }) + }, 150) + }} + activeOpacity={0.7} + > + + Search by name + + + {identificationCheckedIndex === 'manual' && ( + + + + {isFetchingModalSpecies && ( + + + + )} + + {modalSpeciesData?.species && modalSpeciesData.species.length > 0 && ( + + {modalSpeciesData.species.map((item) => ( + setModalManualSearchSelectedSpecies(item as ISpecies)} + activeOpacity={0.7} + > + {item.imageUrl ? ( + { + setFullScreenImageIndex(0) + setFullScreenImages([{ url: item.imageUrl!, organ: 'Species' }]) + }} + style={{ marginRight: 16 }} + activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + ) : ( + + )} + + + {item.commonName} + + {item.scientificName && ( + + {item.scientificName} + + )} + + + + ))} + + )} + + )} + + + )} + + + + )} + + ) } From 7f019b755680b16997c74d5a8482a1381cc679d6 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Mon, 9 Mar 2026 20:00:29 -1000 Subject: [PATCH 04/10] Split components off from AddEdit plant --- apps/mobile/src/app/plants/add-edit.tsx | 467 ++---------------- apps/mobile/src/components/ImageGallery.tsx | 88 ++++ .../components/PlantIdentificationModal.tsx | 354 +++++++++++++ .../components/PlantLifecycleChangeModal.tsx | 84 ++++ 4 files changed, 565 insertions(+), 428 deletions(-) create mode 100644 apps/mobile/src/components/ImageGallery.tsx create mode 100644 apps/mobile/src/components/PlantIdentificationModal.tsx create mode 100644 apps/mobile/src/components/PlantLifecycleChangeModal.tsx diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 69cd327..77434e3 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react' -import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native' +import React, { useEffect, useState } from 'react' +import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import * as Device from 'expo-device' import * as ImagePicker from 'expo-image-picker' import { Ionicons } from '@expo/vector-icons' @@ -8,46 +8,44 @@ import { keepPreviousData } from '@tanstack/react-query' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { ISpecies } from '@plannting/api/dist/models/Species' -import type { PlantIdentificationImage } from '@plannting/api/dist/services/plantIdentification' - import { DateTimePicker } from '../../components/DateTimePicker' import { FertilizerRecommendations } from '../../components/FertilizerRecommendations' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SegmentedControl } from '../../components/SegmentedControl' -import { ShimmerText } from '../../components/ShimmerText' +import { PlantIdentificationModal } from '../../components/PlantIdentificationModal' +import { PlantLifecycleChangeModal } from '../../components/PlantLifecycleChangeModal' import { SpeciesCard } from '../../components/SpeciesCard' import { TextInput } from '../../components/TextInput' import { useAlert } from '../../contexts/AlertContext' import { useAuth } from '../../contexts/AuthContext' -import { useDebounce } from '../../hooks/useDebounce' import { useRefreshOnFocus } from '../../hooks/useRefetchOnFocus' import { trpc } from '../../trpc' -import { getLifecycleLabelWithIcon, LIFECYCLE_ICONS, type PlantLifecycle } from '../../utils/lifecycle' +import { LIFECYCLE_ICONS, type PlantLifecycle } from '../../utils/lifecycle' import { getPlantPhotoImageSource } from '../../utils/plantPhoto' import { palette, styles } from '../../styles' type FormData = { - name: string, - plantedAt: Date | null, lifecycle: PlantLifecycle | '', lifecycleChangeDate: Date | null, + name: string, notes: string, + plantedAt: Date | null, speciesId: string | null, } const initialFormData: FormData = { - name: '', - plantedAt: null, lifecycle: 'start', lifecycleChangeDate: null, + name: '', notes: '', + plantedAt: null, speciesId: null, } @@ -77,14 +75,6 @@ export function AddEditPlantScreen() { const [showLifecycleChangeModal, setShowLifecycleChangeModal] = useState(false) const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = useState(false) const [showIdentificationModal, setShowIdentificationModal] = useState(false) - const [fullScreenImages, setFullScreenImages] = useState(null) - const [fullScreenImageIndex, setFullScreenImageIndex] = useState(0) - const [identificationCheckedIndex, setIdentificationCheckedIndex] = useState(null) - const [modalManualSearchQuery, setModalManualSearchQuery] = useState('') - const [modalManualSearchSelectedSpecies, setModalManualSearchSelectedSpecies] = useState(null) - const { width: windowWidth } = useWindowDimensions() - const identificationModalScrollRef = useRef(null) - const manualSearchSectionYRef = useRef(0) useEffect(() => { if (mode !== 'add') { @@ -120,38 +110,14 @@ export function AddEditPlantScreen() { { enabled: speciesSearchQuery.length > 2 && showSpeciesSuggestions } ) - const debouncedModalManualSearchQuery = useDebounce(modalManualSearchQuery.trim(), 300) - - const { data: modalSpeciesData, isFetching: isFetchingModalSpecies } = trpc.species.list.useQuery( - { q: debouncedModalManualSearchQuery || undefined }, - { enabled: showIdentificationModal && identificationCheckedIndex === 'manual' && debouncedModalManualSearchQuery.length > 2 } - ) - useRefreshOnFocus(() => { refetch() }) - // When manual species results appear, scroll so the list is visible - const prevManualResultsLengthRef = useRef(0) - useEffect(() => { - const len = modalSpeciesData?.species?.length ?? 0 - if (identificationCheckedIndex === 'manual' && len > 0 && prevManualResultsLengthRef.current === 0) { - const t = setTimeout(() => { - identificationModalScrollRef.current?.scrollTo({ - y: manualSearchSectionYRef.current + 120, - animated: true, - }) - }, 300) - prevManualResultsLengthRef.current = len - return () => clearTimeout(t) - } - prevManualResultsLengthRef.current = len - }, [identificationCheckedIndex, modalSpeciesData?.species?.length]) - const plant = data?.plants?.find(p => p._id === id) /** Saved photo URL from API (list response includes photoUrl at runtime). */ - const savedPhotoUrl = plant ? (plant as { photoUrl?: string | null }).photoUrl ?? null : null + const savedPhotoUrl = plant ? plant.photoUrl ?? null : null /** User has selected a new photo this session (local file or just-uploaded). */ const hasNewPhotoThisSession = !!( @@ -194,16 +160,15 @@ export function AddEditPlantScreen() { } // Show existing uploaded photo when editing (don't overwrite if user just picked a new photo) - const savedUrl = (plant as { photoUrl?: string | null }).photoUrl ?? null + const savedUrl = plant.photoUrl ?? null if (!hasNewPhotoThisSession) { setUserRemovedPhoto(false) setIdentificationPhotoUri(savedUrl) setIdentificationPhotoBase64(null) } - }, [ plant, - (plant as { photoUrl?: string | null })?.photoUrl, + plant?.photoUrl, hasNewPhotoThisSession, setFormData, setSelectedSpecies, @@ -728,392 +693,38 @@ export function AddEditPlantScreen() { )} - - - - - Lifecycle change - - - - - - - When did lifecycle change to {formData.lifecycle ? getLifecycleLabelWithIcon(formData.lifecycle) : ''}? - { - if (selectedDate) { - setFormData(formData => ({ ...formData, lifecycleChangeDate: selectedDate })) - setShowLifecycleChangeDatePicker(false) - } - }} - setShowPicker={setShowLifecycleChangeDatePicker} - showPicker={showLifecycleChangeDatePicker} - value={formData.lifecycleChangeDate} - /> - - - {updateMutation.isPending ? 'Saving...' : 'Save'} - - - - - Cancel - - - - - + lifecycle={formData.lifecycle} + lifecycleChangeDate={formData.lifecycleChangeDate} + showPicker={showLifecycleChangeDatePicker} + setShowPicker={setShowLifecycleChangeDatePicker} + onDateChange={(date) => { + if (date) setFormData(formData => ({ ...formData, lifecycleChangeDate: date })) + }} + onConfirm={handleLifecycleChangeDateConfirm} + onCancel={handleLifecycleChangeDateCancel} + isPending={updateMutation.isPending} + /> - { + onClose={() => setShowIdentificationModal(false)} + identifyMutation={{ + data: identifyByImagesMutation.data ?? null, + isPending: isLoadingIdentification, + isError: identifyByImagesMutation.isError, + }} + onApplyIdentification={(scientificName) => { + setSpeciesSearchQuery(scientificName) + setShowSpeciesSuggestions(true) setShowIdentificationModal(false) - setFullScreenImages(null) - setIdentificationCheckedIndex(null) - setModalManualSearchQuery('') - setModalManualSearchSelectedSpecies(null) }} - > - - {fullScreenImages && fullScreenImages.length > 0 ? ( - - { - const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth) - setFullScreenImageIndex(index) - }} - style={{ flex: 1 }} - contentContainerStyle={{ flexGrow: 1 }} - > - {fullScreenImages.map((img, index) => ( - setFullScreenImages(null)} - > - - - ))} - - - - {fullScreenImages[fullScreenImageIndex]?.organ ?? ''} - - {fullScreenImages.length > 1 && ( - - {fullScreenImageIndex + 1} / {fullScreenImages.length} - - )} - - setFullScreenImages(null)} - style={{ - position: 'absolute', - top: 60, - left: 20, - padding: 8, - backgroundColor: 'rgba(255,255,255,0.2)', - borderRadius: 22, - }} - > - ← Back - - - ) : ( - <> - - { setShowIdentificationModal(false); setFullScreenImages(null); setIdentificationCheckedIndex(null); setModalManualSearchQuery(''); setModalManualSearchSelectedSpecies(null) }}> - - - - Plant Identification - - { - if (identificationCheckedIndex === null) return - if (identificationCheckedIndex === 'manual') { - if (modalManualSearchSelectedSpecies) { - handleSpeciesSelect(modalManualSearchSelectedSpecies as NonNullable['species'][0]) - setShowIdentificationModal(false) - setFullScreenImages(null) - setIdentificationCheckedIndex(null) - setModalManualSearchQuery('') - setModalManualSearchSelectedSpecies(null) - } - return - } - const result = identifyByImagesMutation.data?.[identificationCheckedIndex] - if (result) { - setSpeciesSearchQuery(result.scientificName) - setShowSpeciesSuggestions(true) - setShowIdentificationModal(false) - setFullScreenImages(null) - setIdentificationCheckedIndex(null) - } - }} - disabled={ - identificationCheckedIndex === null || - (identificationCheckedIndex === 'manual' && !modalManualSearchSelectedSpecies) - } - style={{ padding: 8 }} - > - - - - - - {isLoadingIdentification ? ( - - - Thinking... - - ) : ( - - {identifyByImagesMutation.data && identifyByImagesMutation.data.length === 0 && ( - - 🤷 - Dang! - No results found. Try taking a clearer photo or search below. - - )} - {identifyByImagesMutation.isError && ( - - An error occurred during identification. Try again or search below. - - )} - {(identifyByImagesMutation.data ?? []).map((result, index) => ( - setIdentificationCheckedIndex(index)} - activeOpacity={0.7} - > - {result.images?.[0]?.url ? ( - { - setFullScreenImageIndex(0) - setFullScreenImages(result.images ?? []) - }} - style={{ marginRight: 16 }} - activeOpacity={0.7} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - - - - - ) : ( - - )} - - - {result.commonNames && result.commonNames.length > 0 - ? result.commonNames[0] - : result.scientificName} - - {result.commonNames && result.commonNames.length > 0 && ( - - {result.scientificName} - - )} - - Confidence: {(result.confidence * 100).toFixed(1)}% - - - - - ))} - { manualSearchSectionYRef.current = e.nativeEvent.layout.y }} - > - { - setIdentificationCheckedIndex('manual') - setTimeout(() => { - identificationModalScrollRef.current?.scrollTo({ - y: manualSearchSectionYRef.current, - animated: true, - }) - }, 150) - }} - activeOpacity={0.7} - > - - Search by name - - - {identificationCheckedIndex === 'manual' && ( - - - - {isFetchingModalSpecies && ( - - - - )} - - {modalSpeciesData?.species && modalSpeciesData.species.length > 0 && ( - - {modalSpeciesData.species.map((item) => ( - setModalManualSearchSelectedSpecies(item as ISpecies)} - activeOpacity={0.7} - > - {item.imageUrl ? ( - { - setFullScreenImageIndex(0) - setFullScreenImages([{ url: item.imageUrl!, organ: 'Species' }]) - }} - style={{ marginRight: 16 }} - activeOpacity={0.7} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - - - - - ) : ( - - )} - - - {item.commonName} - - {item.scientificName && ( - - {item.scientificName} - - )} - - - - ))} - - )} - - )} - - - )} - - - - )} - - + onApplySpecies={(species) => { + handleSpeciesSelect(species as NonNullable['species'][0]) + setShowIdentificationModal(false) + }} + /> ) } diff --git a/apps/mobile/src/components/ImageGallery.tsx b/apps/mobile/src/components/ImageGallery.tsx new file mode 100644 index 0000000..40c2d5d --- /dev/null +++ b/apps/mobile/src/components/ImageGallery.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import { Image, ScrollView, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native' + +export type ImageGalleryProps = { + images: ImageGalleryItem[], + onClose: () => void, +} + +export type ImageGalleryItem = { + caption?: string, + url: string, +} + +/** + * Full-screen horizontal paging gallery with optional caption per slide (or first slide caption only). + * Tap image or Back to close via onClose. + */ +export function ImageGallery({ + images, + onClose, +}: ImageGalleryProps) { + const { width: windowWidth } = useWindowDimensions() + const [currentIndex, setCurrentIndex] = useState(0) + + if (!images.length) { + return null + } + + const caption = images[currentIndex]?.caption ?? '' + + return ( + + { + const index = Math.round(e.nativeEvent.contentOffset.x / windowWidth) + setCurrentIndex(index) + }} + style={{ flex: 1 }} + contentContainerStyle={{ flexGrow: 1 }} + > + {images.map((item, index) => ( + + + + ))} + + {(caption || images.length > 1) && ( + + {!!caption && ( + + {caption} + + )} + {images.length > 1 && ( + + {currentIndex + 1} / {images.length} + + )} + + )} + + ← Back + + + ) +} diff --git a/apps/mobile/src/components/PlantIdentificationModal.tsx b/apps/mobile/src/components/PlantIdentificationModal.tsx new file mode 100644 index 0000000..8d3310a --- /dev/null +++ b/apps/mobile/src/components/PlantIdentificationModal.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useRef, useState } from 'react' +import { ActivityIndicator, Image, KeyboardAvoidingView, Modal, Platform, ScrollView, Text, TouchableOpacity, View } from 'react-native' +import { Ionicons } from '@expo/vector-icons' + +import type { ISpecies } from '@plannting/api/dist/models/Species' + +import { useDebounce } from '../hooks/useDebounce' +import { trpc } from '../trpc' +import { palette, styles } from '../styles' + +import { ImageGallery, type ImageGalleryItem } from './ImageGallery' +import { ShimmerText } from './ShimmerText' +import { TextInput } from './TextInput' + +export type PlantIdentificationModalProps = { + identifyMutation: { + data?: IdentifyResult[] | null, + isPending: boolean, + isError: boolean, + }, + onApplyIdentification: (scientificName: string) => void, + onApplySpecies: (species: SpeciesListItem) => void, + onClose: () => void, + visible: boolean, +} + +type IdentifyResult = { + commonNames?: string[], + confidence: number, + images?: Array<{ url: string, organ?: string }>, + scientificName: string, +} + +type SpeciesListItem = { + _id: string, + commonName: string, + imageUrl?: string | null, + scientificName?: string | null, +} + +export function PlantIdentificationModal({ + identifyMutation, + onApplyIdentification, + onApplySpecies, + onClose, + visible, +}: PlantIdentificationModalProps) { + const [galleryItems, setGalleryItems] = useState(null) + const [checkedIndex, setCheckedIndex] = useState(null) + const [manualSearchQuery, setManualSearchQuery] = useState('') + const [selectedManualSpecies, setSelectedManualSpecies] = useState(null) + const scrollRef = useRef(null) + const manualSectionYRef = useRef(0) + + const debouncedManualQuery = useDebounce(manualSearchQuery.trim(), 300) + const { data: modalSpeciesData, isFetching: isFetchingModalSpecies } = trpc.species.list.useQuery( + { q: debouncedManualQuery || undefined }, + { enabled: visible && checkedIndex === 'manual' && debouncedManualQuery.length > 2 } + ) + + const resetAndClose = () => { + setGalleryItems(null) + setCheckedIndex(null) + setManualSearchQuery('') + setSelectedManualSpecies(null) + onClose() + } + + useEffect(() => { + if (visible) { + return + } + + setGalleryItems(null) + setCheckedIndex(null) + setManualSearchQuery('') + setSelectedManualSpecies(null) + }, [visible]) + + const prevManualResultsLengthRef = useRef(0) + useEffect(() => { + const len = modalSpeciesData?.species?.length ?? 0 + if (checkedIndex === 'manual' && len > 0 && prevManualResultsLengthRef.current === 0) { + const t = setTimeout(() => { + scrollRef.current?.scrollTo({ + y: manualSectionYRef.current + 120, + animated: true, + }) + }, 300) + prevManualResultsLengthRef.current = len + + return () => clearTimeout(t) + } + prevManualResultsLengthRef.current = len + }, [checkedIndex, modalSpeciesData?.species?.length]) + + const { data, isPending: isLoadingIdentification, isError } = identifyMutation + const results = data ?? [] + + return ( + + + {galleryItems && galleryItems.length > 0 ? ( + setGalleryItems(null)} /> + ) : ( + <> + + + + + Plant Identification + { + if (checkedIndex === null) return + if (checkedIndex === 'manual') { + if (selectedManualSpecies) { + onApplySpecies(selectedManualSpecies) + resetAndClose() + } + + return + } + const result = results[checkedIndex] + if (result) { + onApplyIdentification(result.scientificName) + resetAndClose() + } + }} + disabled={checkedIndex === null || (checkedIndex === 'manual' && !selectedManualSpecies)} + style={{ padding: 8 }} + > + + + + + + {isLoadingIdentification ? ( + + + Thinking... + + ) : ( + + {data && data.length === 0 && ( + + 🤷 + Dang! + No results found. Try taking a clearer photo or search below. + + )} + {isError && ( + + An error occurred during identification. Try again or search below. + + )} + {results.map((result, index) => ( + setCheckedIndex(index)} + activeOpacity={0.7} + > + {result.images?.[0]?.url ? ( + setGalleryItems(toGalleryItems(result.images))} + style={{ marginRight: 16 }} + activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + ) : ( + + )} + + + {result.commonNames && result.commonNames.length > 0 ? result.commonNames[0] : result.scientificName} + + {result.commonNames && result.commonNames.length > 0 && ( + + {result.scientificName} + + )} + + Confidence: {(result.confidence * 100).toFixed(1)}% + + + + + ))} + { manualSectionYRef.current = e.nativeEvent.layout.y }}> + { + setCheckedIndex('manual') + setTimeout(() => { + scrollRef.current?.scrollTo({ + y: manualSectionYRef.current, + animated: true, + }) + }, 150) + }} + activeOpacity={0.7} + > + + Search by name + + + {checkedIndex === 'manual' && ( + + + + {isFetchingModalSpecies && ( + + + + )} + + {modalSpeciesData?.species && modalSpeciesData.species.length > 0 && ( + + {modalSpeciesData.species.map((item) => ( + setSelectedManualSpecies(item)} + activeOpacity={0.7} + > + {item.imageUrl ? ( + setGalleryItems([{ url: item.imageUrl!, caption: 'Species' }])} + style={{ marginRight: 16 }} + activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + ) : ( + + )} + + + {item.commonName} + + {item.scientificName && ( + {item.scientificName} + )} + + + + ))} + + )} + + )} + + + )} + + + + )} + + + ) +} + +function toGalleryItems(images: Array<{ url: string, organ?: string }> | undefined): ImageGalleryItem[] { + if (!images?.length) return [] + + return images.map(img => ({ + url: img.url, + caption: img.organ, + })) +} diff --git a/apps/mobile/src/components/PlantLifecycleChangeModal.tsx b/apps/mobile/src/components/PlantLifecycleChangeModal.tsx new file mode 100644 index 0000000..78983b9 --- /dev/null +++ b/apps/mobile/src/components/PlantLifecycleChangeModal.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { Modal, ScrollView, Text, TouchableOpacity, View } from 'react-native' + +import { getLifecycleLabelWithIcon, type PlantLifecycle } from '../utils/lifecycle' +import { palette, styles } from '../styles' + +import { DateTimePicker } from './DateTimePicker' + +export type PlantLifecycleChangeModalProps = { + isPending: boolean, + lifecycle: PlantLifecycle | '', + lifecycleChangeDate: Date | null, + onCancel: () => void, + onConfirm: () => void, + onDateChange: (date: Date | null) => void, + setShowPicker: (show: boolean) => void, + showPicker: boolean, + visible: boolean, +} + +export function PlantLifecycleChangeModal({ + isPending, + lifecycle, + lifecycleChangeDate, + onCancel, + onConfirm, + onDateChange, + setShowPicker, + showPicker, + visible, +}: PlantLifecycleChangeModalProps) { + return ( + + + + + Lifecycle change + + + + + + + When did lifecycle change to {lifecycle ? getLifecycleLabelWithIcon(lifecycle) : ''}? + { + if (selectedDate) { + onDateChange(selectedDate) + setShowPicker(false) + } + }} + setShowPicker={setShowPicker} + showPicker={showPicker} + value={lifecycleChangeDate} + /> + + + {isPending ? 'Saving...' : 'Save'} + + + + + Cancel + + + + + + ) +} From f2471e0ed71c8518e1375df9c57bbd1b7a2ef718 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Mon, 9 Mar 2026 21:14:55 -1000 Subject: [PATCH 05/10] Prevent plant details header image from scrolling down to reveal blank space --- apps/mobile/src/app/plants/[id].tsx | 34 +++++++++++++++---- apps/mobile/src/components/ScreenWrapper.tsx | 25 +++++++++++--- .../mobile/src/hooks/usePullDownToRefresh.tsx | 12 +++++-- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx index b504829..891afbd 100644 --- a/apps/mobile/src/app/plants/[id].tsx +++ b/apps/mobile/src/app/plants/[id].tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { Image as RNImage, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { Animated, Image as RNImage, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Ionicons } from '@expo/vector-icons' import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -27,12 +27,15 @@ import { getPlantPhotoImageSource } from '../../utils/plantPhoto' import { palette, styles } from '../../styles' +const HEADER_IMAGE_HEIGHT = 220 + export default function PlantDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() const navigation = useNavigation() const insets = useSafeAreaInsets() const { token } = useAuth() + const scrollY = React.useRef(new Animated.Value(0)).current const [showChat, setShowChat] = React.useState(false) const [showChoreForm, setShowChoreForm] = React.useState(false) @@ -122,17 +125,35 @@ export default function PlantDetailScreen() { const headerImageSource = plant ? getPlantPhotoImageSource({ plant }, { token }) : null + // We want a “sticky on overscroll” header: + // - When you pull down (ScrollView contentOffset.y < 0), the scroll view bounces and would normally drag + // the header image down with the rest of the content. + // - By translating the header by the *same negative amount*, we cancel that movement so the image appears + // visually fixed in place during pull-down. + // - When scrolling normally (contentOffset.y >= 0), this stays at 0 so the header scrolls away as usual. + const headerTranslateY = scrollY.interpolate({ + inputRange: [-1000, 0], + outputRange: [-1000, 0], + extrapolate: 'clamp', + }) + return ( <> - {/* Header photo scrolls with content - full width, no side padding */} - + {/* Header photo: scrolls away normally, but stays fixed during pull-down (negative scroll). */} + {headerImageSource ? ( )} - + @@ -353,7 +374,8 @@ const localStyles = StyleSheet.create({ backgroundColor: '#fff', }, scrollContent: { - paddingTop: 0, + // Override ScreenWrapper's default content container padding + padding: 0, paddingHorizontal: 0, paddingBottom: 24, rowGap: 24, @@ -363,7 +385,7 @@ const localStyles = StyleSheet.create({ }, headerImageContainer: { width: '100%', - height: 220, + height: HEADER_IMAGE_HEIGHT, backgroundColor: palette.surfaceMuted, }, headerImage: { diff --git a/apps/mobile/src/components/ScreenWrapper.tsx b/apps/mobile/src/components/ScreenWrapper.tsx index d5d55df..06efb45 100644 --- a/apps/mobile/src/components/ScreenWrapper.tsx +++ b/apps/mobile/src/components/ScreenWrapper.tsx @@ -1,5 +1,13 @@ import React from 'react' -import { ScrollView, type StyleProp, StyleSheet, type ViewStyle } from 'react-native' +import { + Animated, + ScrollView, + StyleSheet, + type NativeScrollEvent, + type NativeSyntheticEvent, + type StyleProp, + type ViewStyle, +} from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { usePullDownToRefresh } from '../hooks/usePullDownToRefresh' @@ -11,7 +19,10 @@ type ScreenWrapperProps = { constrainToSafeArea?: boolean, contentContainerStyle?: StyleProp, onRefresh?: () => Promise | void, + onScroll?: (event: NativeSyntheticEvent) => void, + scrollEventThrottle?: number, scrollViewRef?: React.RefObject, + showLoadingIndicatorOnRefresh?: boolean, style?: StyleProp, } @@ -34,14 +45,18 @@ export function ScreenWrapper({ constrainToSafeArea = true, contentContainerStyle, onRefresh, + onScroll, + scrollEventThrottle, scrollViewRef, + showLoadingIndicatorOnRefresh = true, style, }: ScreenWrapperProps) { const insets = useSafeAreaInsets() - const { refreshControl } = usePullDownToRefresh(onRefresh) + const { refreshControl } = usePullDownToRefresh({ onRefresh, showLoadingIndicatorOnRefresh }) const [scrollEnabled, setScrollEnabled] = React.useState(true) const disableCountRef = React.useRef(0) const internalScrollViewRef = React.useRef(null) + const AnimatedScrollView = React.useMemo(() => Animated.createAnimatedComponent(ScrollView), []) // Use provided ref or internal ref const scrollRef = scrollViewRef || internalScrollViewRef @@ -65,15 +80,17 @@ export function ScreenWrapper({ return ( - {children} - + ) } diff --git a/apps/mobile/src/hooks/usePullDownToRefresh.tsx b/apps/mobile/src/hooks/usePullDownToRefresh.tsx index c5ec342..8ad021c 100644 --- a/apps/mobile/src/hooks/usePullDownToRefresh.tsx +++ b/apps/mobile/src/hooks/usePullDownToRefresh.tsx @@ -3,7 +3,15 @@ import { RefreshControl } from 'react-native' import { palette } from '../styles' -export function usePullDownToRefresh(onRefresh?: () => Promise | void) { +export type UsePullDownToRefreshProps = { + onRefresh?: () => Promise | void, + showLoadingIndicatorOnRefresh?: boolean, +} + +export function usePullDownToRefresh({ + onRefresh, + showLoadingIndicatorOnRefresh, +}: UsePullDownToRefreshProps) { const [refreshing, setRefreshing] = useState(false) const handleRefresh = useCallback(async () => { @@ -27,7 +35,7 @@ export function usePullDownToRefresh(onRefresh?: () => Promise | void) return ( Date: Mon, 9 Mar 2026 21:58:58 -1000 Subject: [PATCH 06/10] Better UI/UX in PlantIdentificationModal --- .../components/PlantIdentificationModal.tsx | 156 +++++++++++++----- 1 file changed, 119 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/components/PlantIdentificationModal.tsx b/apps/mobile/src/components/PlantIdentificationModal.tsx index 8d3310a..16bfb54 100644 --- a/apps/mobile/src/components/PlantIdentificationModal.tsx +++ b/apps/mobile/src/components/PlantIdentificationModal.tsx @@ -51,18 +51,68 @@ export function PlantIdentificationModal({ const [selectedManualSpecies, setSelectedManualSpecies] = useState(null) const scrollRef = useRef(null) const manualSectionYRef = useRef(0) + const lastScrollYRef = useRef(0) + const restoreScrollYRef = useRef(null) + const isApplyEnabled = checkedIndex !== null && (checkedIndex !== 'manual' || !!selectedManualSpecies) + + const openGallery = (items: ImageGalleryItem[]) => { + restoreScrollYRef.current = lastScrollYRef.current + setGalleryItems(items) + } + + const closeGallery = () => { + setGalleryItems(null) + } const debouncedManualQuery = useDebounce(manualSearchQuery.trim(), 300) + const hasTriggeredManualSearch = visible && checkedIndex === 'manual' && debouncedManualQuery.length > 2 const { data: modalSpeciesData, isFetching: isFetchingModalSpecies } = trpc.species.list.useQuery( { q: debouncedManualQuery || undefined }, { enabled: visible && checkedIndex === 'manual' && debouncedManualQuery.length > 2 } ) + const showNoManualResults = + hasTriggeredManualSearch && + !isFetchingModalSpecies && + (modalSpeciesData?.species?.length ?? 0) === 0 + + useEffect(() => { + if (galleryItems && galleryItems.length > 0) return + const y = restoreScrollYRef.current + if (y === null) return + + const t = setTimeout(() => { + scrollRef.current?.scrollTo({ y, animated: false }) + restoreScrollYRef.current = null + }, 50) + + return () => clearTimeout(t) + }, [galleryItems]) + + const prevShowNoManualResultsRef = useRef(false) + useEffect(() => { + if (!showNoManualResults || prevShowNoManualResultsRef.current) { + prevShowNoManualResultsRef.current = showNoManualResults + return + } + + prevShowNoManualResultsRef.current = true + const t = setTimeout(() => { + scrollRef.current?.scrollTo({ + y: manualSectionYRef.current + 180, + animated: true, + }) + }, 150) + + return () => clearTimeout(t) + }, [showNoManualResults]) const resetAndClose = () => { setGalleryItems(null) setCheckedIndex(null) setManualSearchQuery('') setSelectedManualSpecies(null) + lastScrollYRef.current = 0 + restoreScrollYRef.current = null onClose() } @@ -75,6 +125,8 @@ export function PlantIdentificationModal({ setCheckedIndex(null) setManualSearchQuery('') setSelectedManualSpecies(null) + lastScrollYRef.current = 0 + restoreScrollYRef.current = null }, [visible]) const prevManualResultsLengthRef = useRef(0) @@ -106,45 +158,59 @@ export function PlantIdentificationModal({ > {galleryItems && galleryItems.length > 0 ? ( - setGalleryItems(null)} /> + ) : ( <> - - - - - Plant Identification - { - if (checkedIndex === null) return - if (checkedIndex === 'manual') { - if (selectedManualSpecies) { - onApplySpecies(selectedManualSpecies) + + + + + + { + if (checkedIndex === null) return + if (checkedIndex === 'manual') { + if (selectedManualSpecies) { + onApplySpecies(selectedManualSpecies) + resetAndClose() + } + + return + } + const result = results[checkedIndex] + if (result) { + onApplyIdentification(result.scientificName) resetAndClose() } - - return - } - const result = results[checkedIndex] - if (result) { - onApplyIdentification(result.scientificName) - resetAndClose() - } - }} - disabled={checkedIndex === null || (checkedIndex === 'manual' && !selectedManualSpecies)} - style={{ padding: 8 }} - > - - + }} + disabled={!isApplyEnabled} + accessibilityRole="button" + accessibilityLabel="Accept selected species" + style={{ + minWidth: 44, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 999, + borderWidth: 1, + borderColor: isApplyEnabled ? palette.primary : palette.border, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }} + > + + + Accept + + + + + What type plant is this? + { + lastScrollYRef.current = e.nativeEvent.contentOffset.y + }} + scrollEventThrottle={16} > {isLoadingIdentification ? ( @@ -194,7 +264,7 @@ export function PlantIdentificationModal({ > {result.images?.[0]?.url ? ( setGalleryItems(toGalleryItems(result.images))} + onPress={() => openGallery(toGalleryItems(result.images))} style={{ marginRight: 16 }} activeOpacity={0.7} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} @@ -214,6 +284,11 @@ export function PlantIdentificationModal({ {result.commonNames && result.commonNames.length > 0 ? result.commonNames[0] : result.scientificName} + {result.commonNames && result.commonNames.length > 1 && result.commonNames.slice(1).map(commonName => ( + + aka {commonName} + + ))} {result.commonNames && result.commonNames.length > 0 && ( {result.scientificName} @@ -297,7 +372,7 @@ export function PlantIdentificationModal({ > {item.imageUrl ? ( setGalleryItems([{ url: item.imageUrl!, caption: 'Species' }])} + onPress={() => openGallery([{ url: item.imageUrl!, caption: 'Species' }])} style={{ marginRight: 16 }} activeOpacity={0.7} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} @@ -330,6 +405,13 @@ export function PlantIdentificationModal({ ))} )} + {showNoManualResults && ( + + + No species found for “{debouncedManualQuery}”. + + + )} )} From 5604b1ca8a15b9a9567e47b45635a87f70d848e7 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 10 Mar 2026 11:07:41 -1000 Subject: [PATCH 07/10] Show loading indicator while photo upload is processing/uploading --- apps/mobile/src/app/plants/add-edit.tsx | 34 +++++++++++++++++-- .../components/PlantIdentificationModal.tsx | 3 ++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 77434e3..1ad62f4 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' +import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Modal, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import * as Device from 'expo-device' import * as ImagePicker from 'expo-image-picker' import { Ionicons } from '@expo/vector-icons' @@ -75,6 +75,8 @@ export function AddEditPlantScreen() { const [showLifecycleChangeModal, setShowLifecycleChangeModal] = useState(false) const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = useState(false) const [showIdentificationModal, setShowIdentificationModal] = useState(false) + /** Shown while photo is processing/uploading before the identification modal appears. */ + const [showIdentificationLoadingOverlay, setShowIdentificationLoadingOverlay] = useState(false) useEffect(() => { if (mode !== 'add') { @@ -210,6 +212,14 @@ export function AddEditPlantScreen() { ...identifyByImagesMutation } = trpc.plants.identifyByImages.useMutation() + // When identification request finishes, hide loading overlay and show identification modal (one modal at a time so the identification modal is not blocked) + useEffect(() => { + if (showIdentificationLoadingOverlay && !isLoadingIdentification) { + setShowIdentificationLoadingOverlay(false) + setShowIdentificationModal(true) + } + }, [showIdentificationLoadingOverlay, isLoadingIdentification]) + const handleSpeciesSelect = (species: NonNullable['species'][0]) => { // Update speciesId and set name to commonName if name is empty const newName = formData.name.trim() === '' ? species.commonName : formData.name @@ -303,7 +313,7 @@ export function AddEditPlantScreen() { setIdentificationPhotoUri(asset.uri) setIdentificationPhotoBase64(asset.base64 ?? null) if (asset.base64 && !selectedSpecies) { - setShowIdentificationModal(true) + setShowIdentificationLoadingOverlay(true) identifyByImagesMutation.mutate({ images: [asset.base64] }) } } @@ -707,9 +717,27 @@ export function AddEditPlantScreen() { isPending={updateMutation.isPending} /> + + + + + Processing photo… + + + + setShowIdentificationModal(false)} + onClose={() => { + setShowIdentificationModal(false) + setShowIdentificationLoadingOverlay(false) + }} + onModalShow={() => setShowIdentificationLoadingOverlay(false)} identifyMutation={{ data: identifyByImagesMutation.data ?? null, isPending: isLoadingIdentification, diff --git a/apps/mobile/src/components/PlantIdentificationModal.tsx b/apps/mobile/src/components/PlantIdentificationModal.tsx index 16bfb54..fcd0591 100644 --- a/apps/mobile/src/components/PlantIdentificationModal.tsx +++ b/apps/mobile/src/components/PlantIdentificationModal.tsx @@ -21,6 +21,7 @@ export type PlantIdentificationModalProps = { onApplyIdentification: (scientificName: string) => void, onApplySpecies: (species: SpeciesListItem) => void, onClose: () => void, + onModalShow?: () => void, visible: boolean, } @@ -43,6 +44,7 @@ export function PlantIdentificationModal({ onApplyIdentification, onApplySpecies, onClose, + onModalShow, visible, }: PlantIdentificationModalProps) { const [galleryItems, setGalleryItems] = useState(null) @@ -155,6 +157,7 @@ export function PlantIdentificationModal({ animationType='slide' presentationStyle='pageSheet' onRequestClose={resetAndClose} + onShow={onModalShow} > {galleryItems && galleryItems.length > 0 ? ( From 608ac29001156765184fa8658c7e7ba105b83d6f Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 10 Mar 2026 11:08:03 -1000 Subject: [PATCH 08/10] Fix double-index warning on UserFertilizerPreferences model --- apps/api/src/models/UserFertilizerPreferences.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/api/src/models/UserFertilizerPreferences.ts b/apps/api/src/models/UserFertilizerPreferences.ts index 4a3f87b..90dcc78 100644 --- a/apps/api/src/models/UserFertilizerPreferences.ts +++ b/apps/api/src/models/UserFertilizerPreferences.ts @@ -27,6 +27,7 @@ export const userFertilizerPreferencesSchema = new mongoose.Schema('UserFertilizerPreferences', userFertilizerPreferencesSchema) From add1ea47a2524609ef00791bc55f732d7aa834ac Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 10 Mar 2026 11:16:15 -1000 Subject: [PATCH 09/10] Minor wording update --- apps/mobile/src/app/plants/add-edit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 1ad62f4..e24e871 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -484,7 +484,7 @@ export function AddEditPlantScreen() { style={[localStyles.takePhotoButtonContainer, { paddingBottom: 12 }]} > - {displayPhotoUri ? 'Retake' : 'Take'} Photo + {displayPhotoUri ? 'Retake Photo' : 'Take Photo'} Date: Tue, 10 Mar 2026 22:29:13 -1000 Subject: [PATCH 10/10] clean up --- apps/api/src/endpoints/media/plantPhotos/get.ts | 2 +- apps/api/src/endpoints/trpc/me/delete.ts | 2 +- apps/api/src/endpoints/trpc/plants/chat.ts | 2 +- apps/api/src/services/plantIdentification/index.ts | 4 +++- .../src/services/plantIdentification/providers/plantNet.ts | 4 ++-- apps/mobile/src/app/chores/[id].tsx | 2 +- 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/api/src/endpoints/media/plantPhotos/get.ts b/apps/api/src/endpoints/media/plantPhotos/get.ts index 40c0e0a..567261b 100644 --- a/apps/api/src/endpoints/media/plantPhotos/get.ts +++ b/apps/api/src/endpoints/media/plantPhotos/get.ts @@ -61,7 +61,7 @@ export async function getPlantPhoto(req: Request, res: Response): Promise return } - const url = (plant as { photoUrl?: string | null }).photoUrl + const url = plant.photoUrl if (!url) { res.status(404).json({ message: 'Plant has no photo' }) diff --git a/apps/api/src/endpoints/trpc/me/delete.ts b/apps/api/src/endpoints/trpc/me/delete.ts index f2e6851..39718d5 100644 --- a/apps/api/src/endpoints/trpc/me/delete.ts +++ b/apps/api/src/endpoints/trpc/me/delete.ts @@ -53,7 +53,7 @@ export const deleteMe = authProcedure // Delete all plant photos from blob storage for (const plant of plants) { - const photoUrl = (plant as { photoUrl?: string | null }).photoUrl + const photoUrl = plant.photoUrl if (photoUrl) { await blobService.deletePlantPhotoFromBlob(photoUrl) } diff --git a/apps/api/src/endpoints/trpc/plants/chat.ts b/apps/api/src/endpoints/trpc/plants/chat.ts index 3809296..7c719b4 100644 --- a/apps/api/src/endpoints/trpc/plants/chat.ts +++ b/apps/api/src/endpoints/trpc/plants/chat.ts @@ -103,7 +103,7 @@ export const chat = authProcedure model: 'gpt-4o-mini', messages: [ { role: 'system', content: systemParts.join('\n') }, - ...input.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })), + ...input.messages.map(m => ({ role: m.role, content: m.content })), ], }) diff --git a/apps/api/src/services/plantIdentification/index.ts b/apps/api/src/services/plantIdentification/index.ts index 6a488b4..511bb54 100644 --- a/apps/api/src/services/plantIdentification/index.ts +++ b/apps/api/src/services/plantIdentification/index.ts @@ -2,6 +2,7 @@ import * as plantNetProvider from './providers/plantNet' export type PlantIdentificationResult = { id: string | null, // Generated ID that we derive from other fields + source: 'plantNet', commonNames: string[], confidence: number, genus: string, @@ -18,7 +19,7 @@ export type PlantIdentificationImage = { url: string, } -const MIN_CONFIDENCE = 0.0 +const MIN_CONFIDENCE = 0.0 // Don't filter any results out - we will show multiple to the user to choose from export const identifyPlantByImages = async (images: Buffer[]): Promise => { const plantNetResult = await plantNetProvider.identifyPlantByImages(images) @@ -35,6 +36,7 @@ export const identifyPlantByImages = async (images: Buffer[]): Promise +export type PlantNetIdentificationResult = z.infer & { source: 'plantNet' } export type PlantNetResponse = z.infer @@ -105,7 +105,7 @@ export const identifyPlantByImages = async (images: Buffer[]): Promise ({ ...result, source: 'plantNet' as const })) return results || [] } diff --git a/apps/mobile/src/app/chores/[id].tsx b/apps/mobile/src/app/chores/[id].tsx index aee61a8..8c43d2f 100644 --- a/apps/mobile/src/app/chores/[id].tsx +++ b/apps/mobile/src/app/chores/[id].tsx @@ -262,7 +262,7 @@ export default function ChoreDetailScreen() { const title = (fertilizers && fertilizers.length > 0 ? fertilizers.map(f => { - const fertObj = f.fertilizer as any + const fertObj = f.fertilizer const name = fertObj && typeof fertObj === 'object' && fertObj.name ? fertObj.name : '' return `${name}${f.amount ? ` (${f.amount})` : ''}`