diff --git a/apps/api/src/endpoints/plantPhoto/get.ts b/apps/api/src/endpoints/media/plantPhotos/get.ts similarity index 95% rename from apps/api/src/endpoints/plantPhoto/get.ts rename to apps/api/src/endpoints/media/plantPhotos/get.ts index a470073..567261b 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: @@ -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/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/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) diff --git a/apps/api/src/routers/media/index.ts b/apps/api/src/routers/media/index.ts new file mode 100644 index 0000000..dc3f0e0 --- /dev/null +++ b/apps/api/src/routers/media/index.ts @@ -0,0 +1,10 @@ +import { Router } from 'express' + +import { getPlantPhoto } from '../../endpoints/media/plantPhotos/get' + +const mediaRouter = Router() + +mediaRouter + .get('/plant-photos', getPlantPhoto) + +export { mediaRouter } diff --git a/apps/api/src/services/plantIdentification/index.ts b/apps/api/src/services/plantIdentification/index.ts index 8111111..511bb54 100644 --- a/apps/api/src/services/plantIdentification/index.ts +++ b/apps/api/src/services/plantIdentification/index.ts @@ -1,33 +1,53 @@ import * as plantNetProvider from './providers/plantNet' export type PlantIdentificationResult = { - confidence: number, + id: string | null, // Generated ID that we derive from other fields + source: 'plantNet', 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 // 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) 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, + source: result.source, + 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..e8bded0 100644 --- a/apps/api/src/services/plantIdentification/providers/plantNet.ts +++ b/apps/api/src/services/plantIdentification/providers/plantNet.ts @@ -8,36 +8,49 @@ 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 +export type PlantNetIdentificationResult = z.infer & { source: 'plantNet' } export type PlantNetResponse = z.infer @@ -64,7 +77,7 @@ export const identifyPlantByImages = async (images: Buffer[]): Promise ({ ...result, source: 'plantNet' as const })) return results || [] } diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index bc076b2..3dd343b 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -323,7 +323,6 @@ export function FertilizersScreen() { buttons={ 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 7defcba..a5b4a29 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) @@ -157,7 +155,6 @@ export function PlantsScreen() { buttons={ setFilterModalVisible(true)} - color={palette.brandPrimary} isPulsating={!!searchQuery.trim()} testID='plants-filter-button' /> @@ -220,9 +217,9 @@ export function PlantsScreen() { > - {getPlantPhotoImageSource({ plant }, { apiBaseUrl, token }) ? ( + {getPlantPhotoImageSource({ plant }, { token }) ? ( 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})` : ''}` diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx index c9d13d4..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' @@ -25,16 +25,17 @@ 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' +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 apiBaseUrl = config.api.baseUrl + const scrollY = React.useRef(new Animated.Value(0)).current const [showChat, setShowChat] = React.useState(false) const [showChoreForm, setShowChoreForm] = React.useState(false) @@ -122,7 +123,19 @@ export default function PlantDetailScreen() { }) } - const headerImageSource = plant ? getPlantPhotoImageSource({ plant }, { apiBaseUrl, token }) : null + 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 ( <> @@ -130,11 +143,17 @@ export default function PlantDetailScreen() { - {/* Header photo scrolls with content - full width, no side padding */} - + {/* Header photo: scrolls away normally, but stays fixed during pull-down (negative scroll). */} + {headerImageSource ? ( )} - + @@ -355,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, @@ -365,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/app/plants/add-edit.tsx b/apps/mobile/src/app/plants/add-edit.tsx index 55ded43..e24e871 100644 --- a/apps/mobile/src/app/plants/add-edit.tsx +++ b/apps/mobile/src/app/plants/add-edit.tsx @@ -1,21 +1,21 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useState } from 'react' 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' - 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 { 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 { PlantIdentificationModal } from '../../components/PlantIdentificationModal' +import { PlantLifecycleChangeModal } from '../../components/PlantLifecycleChangeModal' import { SpeciesCard } from '../../components/SpeciesCard' import { TextInput } from '../../components/TextInput' @@ -26,27 +26,26 @@ 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 { config } from '../../config' 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, } @@ -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) @@ -76,6 +74,9 @@ export function AddEditPlantScreen() { const [showPlantedAtDatePicker, setShowPlantedAtDatePicker] = useState(false) 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') { @@ -118,7 +119,7 @@ export function AddEditPlantScreen() { 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 = !!( @@ -161,16 +162,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, @@ -210,14 +210,15 @@ 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() + + // 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 @@ -312,6 +313,7 @@ export function AddEditPlantScreen() { setIdentificationPhotoUri(asset.uri) setIdentificationPhotoBase64(asset.base64 ?? null) if (asset.base64 && !selectedSpecies) { + setShowIdentificationLoadingOverlay(true) identifyByImagesMutation.mutate({ images: [asset.base64] }) } } @@ -391,7 +393,7 @@ export function AddEditPlantScreen() { return ( @@ -468,9 +470,9 @@ export function AddEditPlantScreen() { borderStyle: 'dashed', }} > - {getPlantPhotoSrc && getPlantPhotoImageSource(getPlantPhotoSrc, { apiBaseUrl, token }) && ( + {getPlantPhotoSrc && getPlantPhotoImageSource(getPlantPhotoSrc, { token }) && ( @@ -482,7 +484,7 @@ export function AddEditPlantScreen() { style={[localStyles.takePhotoButtonContainer, { paddingBottom: 12 }]} > - {displayPhotoUri ? 'Retake' : 'Take'} Photo + {displayPhotoUri ? 'Retake Photo' : 'Take Photo'} )} - { + if (date) setFormData(formData => ({ ...formData, lifecycleChangeDate: date })) + }} + onConfirm={handleLifecycleChangeDateConfirm} + onCancel={handleLifecycleChangeDateCancel} + isPending={updateMutation.isPending} + /> + + - - - - Lifecycle change - - - - + + + + Processing photo… - - 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 - - - + + { + setShowIdentificationModal(false) + setShowIdentificationLoadingOverlay(false) + }} + onModalShow={() => setShowIdentificationLoadingOverlay(false)} + identifyMutation={{ + data: identifyByImagesMutation.data ?? null, + isPending: isLoadingIdentification, + isError: identifyByImagesMutation.isError, + }} + onApplyIdentification={(scientificName) => { + setSpeciesSearchQuery(scientificName) + setShowSpeciesSuggestions(true) + setShowIdentificationModal(false) + }} + 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..fcd0591 --- /dev/null +++ b/apps/mobile/src/components/PlantIdentificationModal.tsx @@ -0,0 +1,439 @@ +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, + onModalShow?: () => 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, + onModalShow, + 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 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() + } + + useEffect(() => { + if (visible) { + return + } + + setGalleryItems(null) + setCheckedIndex(null) + setManualSearchQuery('') + setSelectedManualSpecies(null) + lastScrollYRef.current = 0 + restoreScrollYRef.current = 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 ? ( + + ) : ( + <> + + + + + + { + if (checkedIndex === null) return + if (checkedIndex === 'manual') { + if (selectedManualSpecies) { + onApplySpecies(selectedManualSpecies) + resetAndClose() + } + + return + } + const result = results[checkedIndex] + if (result) { + onApplyIdentification(result.scientificName) + resetAndClose() + } + }} + 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 ? ( + + + 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 ? ( + openGallery(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 > 1 && result.commonNames.slice(1).map(commonName => ( + + aka {commonName} + + ))} + {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 ? ( + openGallery([{ 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} + )} + + + + ))} + + )} + {showNoManualResults && ( + + + No species found for “{debouncedManualQuery}”. + + + )} + + )} + + + )} + + + + )} + + + ) +} + +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 + + + + + + ) +} 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 (