import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Image, Linking, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as ImagePicker from 'expo-image-picker'; import * as Location from 'expo-location'; import MapView, { Marker, type Region } from 'react-native-maps'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { identifyPlant } from '../services/plantIdentification/plantIdentificationProvider'; import { PlantIdentificationError } from '../services/plantIdentification/types'; import { PixieAlert } from '../components/PixieAlert'; import { CuttingGuidelinesModal } from '../components/CuttingGuidelinesModal'; import { lookupPlantReference } from '../services/plantReference/plantReferenceProvider'; import { savePlantScanResult } from '../services/plantScanStorage'; import { persistPhoto } from '../services/photoStorage'; import { enrichPlantProfile, getPropagationInfo, getSeasonalCuttingGuidance, currentSeason, getSeedStoragePrep, getSeedPlantingGuide } from '../services/plantEnrichment'; import { cleanPlantDisplayName } from '../utils/plantHelpers'; import { haptic, pressWithHaptic } from '../constants/theme'; import { getWeather } from '../services/weather'; import type { WeatherSnapshot } from '../types/care'; import type { PlantIdentificationInput, PlantIdentificationResult, PlantIdentificationSuggestion, PlantScanPhoto, SeedIdentificationResult, } from '../services/plantIdentification/types'; import type { WishlistItem, WishlistVisibility, FieldDiaryEntry, GpsPrecision, } from '../types/garden'; import type { LocationContext, PlantType, SavedPlantProfile, } from '../types/plantScan'; import type { PlantEnrichmentData, PropagationInfo, } from '../types/propagation'; import { DIFFICULTY_COLORS, ROOTING_METHOD_LABELS, SUCCESS_RATE_LABELS, ROOTING_METHOD_ICONS, cuttingMethodLabel, filterCuttingMethods, } from '../types/propagation'; type Choice = { value: T; label: string }; const plantIdentificationTrustMessage = "Pixie does its best, but plant identification isn't always perfect. Compare details and double-check before making important care, safety, or consumption decisions."; const plantTypes: Choice[] = [ { value: 'houseplant', label: 'Houseplant' }, { value: 'tree', label: 'Tree' }, { value: 'garden', label: 'Garden' }, { value: 'crop', label: 'Crop' }, { value: 'cutting', label: 'Cutting' }, { value: 'unsure', label: 'Unsure' }, ]; const locationContexts: Choice[] = [ { value: 'home', label: 'At home (inside)' }, { value: 'garden_yard', label: 'Garden / yard' }, { value: 'public_park_trail', label: 'Park / trail' }, { value: 'public_spaces', label: 'Public spaces' }, { value: 'nursery_store', label: 'Store / nursery' }, { value: 'unsure', label: 'Where does this grow? 🌿' }, ]; /** Current season (northern hemisphere), for the live cutting context. */ type LocationSource = 'general' | 'precise' | 'manual'; const locationSourceOptions: Choice[] = [ { value: 'general', label: 'My location (rough β€” ~1 sq mile)' }, { value: 'precise', label: 'My location (precise)' }, { value: 'manual', label: 'Enter ZIP / city name' }, ]; type Props = { onBack: () => void; onSavePlant: (profile: SavedPlantProfile) => void; onUpdatePlant: (plantId: string, patch: Partial) => void; onViewPlant: (plantId: string) => void; onSaveWishlist: (item: WishlistItem) => void; onSaveFieldDiaryEntry: (entry: import('../types/garden').FieldDiaryEntry, placeLabel: string) => void; wishlistVisibility: WishlistVisibility; initialImage?: string; savedGpsPrecision: GpsPrecision; rememberGpsPrecision: boolean; /** Saved ZIP/city from app Settings β€” pre-fills the manual location field */ savedApproximateLocationText?: string | null; /** User's care preferences β€” fed to the AI to refine identification/advice */ carePreferences?: { organicFirst?: boolean; petSafeCaution?: boolean; ediblePlantCaution?: boolean; shoppingStyle?: 'diy_first' | 'store_bought' | 'either'; }; /** Scan mode β€” 'plant' (default), 'fungus', or 'seed'. Drives the AI prompt + result shape. */ scanMode?: 'plant' | 'fungus' | 'seed'; }; type ScanPhotoSlotId = 'whole' | 'leaf' | 'flower' | 'fruit' | 'bark' | 'cutting' | 'plant' | 'habitat'; type ScanPhotoState = { uri: string; width?: number; height?: number }; type ScanPhotoSlotDefinition = { id: ScanPhotoSlotId; title: string; requirement: string; required: boolean; helper: string; emptyText: string; }; const generalPlantPhotoSlots: ScanPhotoSlotDefinition[] = [ { id: 'whole', title: 'Whole plant photo', requirement: 'Required', required: true, helper: 'Capture the full shape and growth habit.', emptyText: 'Whole plant photo' }, { id: 'leaf', title: 'Leaf close-up', requirement: 'Required', required: true, helper: 'Show leaf shape, color, and texture clearly.', emptyText: 'Leaf close-up photo' }, { id: 'flower', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'Add this if your plant is flowering.', emptyText: 'Flower photo' }, ]; const treePhotoSlots: ScanPhotoSlotDefinition[] = [ { id: 'whole', title: 'Whole tree / branch structure', requirement: 'Required', required: true, helper: 'Capture the tree shape or a branch section.', emptyText: 'Tree or branch photo' }, { id: 'leaf', title: 'Leaf close-up', requirement: 'Required', required: true, helper: 'Show leaf shape, color, and texture clearly.', emptyText: 'Leaf close-up photo' }, { id: 'bark', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Helpful for many trees.', emptyText: 'Bark photo' }, { id: 'flower', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'Add this if your tree is flowering.', emptyText: 'Flower photo' }, { id: 'fruit', title: 'Fruit close-up', requirement: 'Optional', required: false, helper: 'Add this if your tree has fruit.', emptyText: 'Fruit photo' }, ]; const cuttingPhotoSlots: ScanPhotoSlotDefinition[] = [ { id: 'cutting', title: 'Cutting photo', requirement: 'Required', required: true, helper: 'Show the cutting clearly.', emptyText: 'Cutting photo' }, { id: 'leaf', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'Add this if leaves are visible.', emptyText: 'Leaf close-up photo' }, ]; const gardenAndCropPhotoSlots: ScanPhotoSlotDefinition[] = [ { id: 'whole', title: 'Whole plant photo', requirement: 'Required', required: true, helper: 'Capture the full shape and growth habit.', emptyText: 'Whole plant photo' }, { id: 'leaf', title: 'Leaf close-up', requirement: 'Required', required: true, helper: 'Show leaf shape, color, and texture clearly.', emptyText: 'Leaf close-up photo' }, { id: 'flower', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'Add this if your plant is flowering.', emptyText: 'Flower photo' }, { id: 'fruit', title: 'Fruit close-up', requirement: 'Optional', required: false, helper: 'Add this if your plant has fruit or pods.', emptyText: 'Fruit photo' }, ]; /** Fungus / mushroom intake photo slots (Bekky, 2026-08-25). */ const fungusPhotoSlots: ScanPhotoSlotDefinition[] = [ { id: 'whole', title: 'Fruiting body photo', requirement: 'Required', required: true, helper: 'Capture the cap and stem (or whole fruiting body) clearly.', emptyText: 'Fruiting body photo' }, { id: 'leaf', title: 'Underside / gills close-up', requirement: 'Required', required: true, helper: 'Show the gills, pores, or teeth under the cap.', emptyText: 'Underside / gills photo' }, { id: 'flower', title: 'Base / substrate close-up', requirement: 'Optional', required: false, helper: 'Show the base, ring, volva, or what it grows on.', emptyText: 'Base / substrate photo' }, ]; /** What the user is actually scanning in Fungus Mode (Bekky, 2026-08-25). */ export type FungusKind = 'mushroom' | 'bracket' | 'lichen' | 'moss' | 'unsure'; const fungusKindChoices: { value: FungusKind; label: string }[] = [ { value: 'mushroom', label: 'πŸ„ Mushroom' }, { value: 'bracket', label: 'πŸͺ΅ Bracket / shelf' }, { value: 'lichen', label: 'πŸͺ¨ Lichen' }, { value: 'moss', label: '🌿 Moss' }, { value: 'unsure', label: '❓ Unsure' }, ]; function fungusPhotoSlotsFor(kind: FungusKind): ScanPhotoSlotDefinition[] { switch (kind) { case 'bracket': return [ { id: 'whole', title: 'Fruiting body photo', requirement: 'Required', required: true, helper: 'Capture the whole shelf / bracket fruiting body.', emptyText: 'Fruiting body photo' }, { id: 'leaf', title: 'Underside close-up', requirement: 'Required', required: true, helper: 'Show the pores, gills, or smooth underside.', emptyText: 'Underside photo' }, { id: 'flower', title: 'Host / substrate close-up', requirement: 'Optional', required: false, helper: 'Show the wood or tree it is growing on.', emptyText: 'Host wood photo' }, ]; case 'lichen': return [ { id: 'whole', title: 'Lichen colony photo', requirement: 'Required', required: true, helper: 'Capture the lichen growth (crusty, leafy, or shrubby).', emptyText: 'Lichen photo' }, { id: 'leaf', title: 'Close-up of body', requirement: 'Required', required: true, helper: 'Show the shape, color, and surface detail.', emptyText: 'Lichen close-up' }, { id: 'flower', title: 'Surface it grows on', requirement: 'Optional', required: false, helper: 'Rock, bark, soil, or roof β€” helps ID it.', emptyText: 'Surface photo' }, ]; case 'moss': return [ { id: 'whole', title: 'Moss patch photo', requirement: 'Required', required: true, helper: 'Capture the moss patch and its growth form.', emptyText: 'Moss photo' }, { id: 'leaf', title: 'Close-up of leaves / spore capsule', requirement: 'Required', required: true, helper: 'Show the leaf shape and any spore capsules on stalks.', emptyText: 'Moss close-up' }, { id: 'flower', title: 'Where it grows', requirement: 'Optional', required: false, helper: 'Soil, rock, bark, or wood β€” helps identify it.', emptyText: 'Habit photo' }, ]; case 'unsure': // Generic fungus/plant-like organism (Bekky, 2026-09-02): when the user // isn't sure what they're scanning, don't force mushroom-specific shots. // Ask for the whole organism + a close-up + the surface it grows on. return [ { id: 'whole', title: 'Whole organism photo', requirement: 'Required', required: true, helper: 'Capture the whole thing clearly β€” shape, color, and size.', emptyText: 'Whole organism photo' }, { id: 'leaf', title: 'Close-up of detail', requirement: 'Required', required: true, helper: 'Show the surface detail β€” gills, pores, texture, or leaves.', emptyText: 'Close-up photo' }, { id: 'flower', title: 'What it grows on', requirement: 'Optional', required: false, helper: 'Soil, wood, rock, or bark β€” helps identify it.', emptyText: 'Surface / habitat photo' }, ]; default: return fungusPhotoSlots; } } /** What the user is actually scanning in Seed Mode (Bekky, 2026-09-07, seed-intake * feature). Mirrors the fungus-kind chip pattern: the chip picks the intake path * and shapes the photo slots + AI prompt. */ export type SeedKind = 'packet' | 'fruit' | 'pod' | 'wild'; /** Forced refinement of the seed path (Phase 1B, 2026-09-07): after the main * path chip is picked, the user must pick a refinement chip before a * fruit/pod/wild scan can proceed, so the AI has the context it needs. */ export type SeedRefinement = 'store_bought' | 'wild' | 'unsure' | 'tree' | 'shrub' | 'herb_vine' | 'attached' | 'loose'; /** Plant type refinement for the unknown-wild-seed 'attached' path (Phase 1C, * 2026-09-07): a third chip row shown only when seedKind === 'wild' && * seedRefinement === 'attached'. tree / bush / vine / unsure. */ export type SeedPlantType = 'tree' | 'bush' | 'vine' | 'unsure'; const seedKindChoices: { value: SeedKind; label: string }[] = [ { value: 'packet', label: '🎫 Seed packet' }, { value: 'fruit', label: '🍎 Fruit seed' }, { value: 'pod', label: '🌳 Found pod' }, { value: 'wild', label: '❓ Unknown wild seed' }, ]; /** Refinement chips per main path (Phase 1B). packet needs no refinement β€” the * packet has the info. fruit/pod/wild each get a forced second row. */ const seedRefinementChoices: Record, { value: SeedRefinement; label: string }[]> = { fruit: [ { value: 'store_bought', label: 'πŸ›’ Bought it from the store' }, { value: 'wild', label: '🌿 Found it wild' }, { value: 'unsure', label: '❓ Not sure' }, ], pod: [ { value: 'tree', label: '🌳 From a tree' }, { value: 'shrub', label: '🌿 From a shrub' }, { value: 'herb_vine', label: '🌱 From a herb/vine' }, { value: 'unsure', label: '❓ Not sure' }, ], wild: [ { value: 'attached', label: '🌿 Attached to a plant' }, { value: 'loose', label: 'πŸ‚ Found loose' }, { value: 'unsure', label: '❓ Not sure' }, ], }; /** Third chip row for the unknown-wild-seed 'attached' path (Phase 1C, * 2026-09-07): what kind of plant the seed was attached to. */ const seedPlantTypeChoices: { value: SeedPlantType; label: string }[] = [ { value: 'tree', label: '🌳 Tree' }, { value: 'bush', label: '🌿 Bush' }, { value: 'vine', label: '🌱 Vine' }, { value: 'unsure', label: '❓ Not sure' }, ]; function seedPhotoSlotsFor(kind: SeedKind, refinement?: SeedRefinement, plantType?: SeedPlantType): ScanPhotoSlotDefinition[] { switch (kind) { case 'packet': // Read the packet text β€” species, variety, depth, light, days to maturity. return [ { id: 'whole', title: 'Packet front photo', requirement: 'Required', required: true, helper: 'Capture the packet front so the text is readable.', emptyText: 'Packet front photo' }, { id: 'leaf', title: 'Packet back / details', requirement: 'Optional', required: false, helper: 'Show the back for depth, spacing, and days to maturity.', emptyText: 'Packet back photo' }, ]; case 'fruit': // Species-level lookup β€” the user knows the fruit, not the variety. The // fruit photo is the "before you eat" moment (Bekky, 2026-09-07): capture // the fruit + seed-saving guidance BEFORE eating, so the seed is handled // right. Phase 1C (2026-09-07): the FRUIT photo is the PRIORITY β€” a seed // photo alone never gets an AI hit. The user may not have the seed yet // (they're waiting for the app to tell them how to safely remove + clean // it), so the seed close-up is OPTIONAL and the fruit photo is REQUIRED. // Phase 1D (2026-09-07): if BOUGHT from the store, no plant photos (the // fruit is enough β€” asking for the plant it grew on is nonsensical for a // store-bought fruit). If FOUND WILD, offer the FULL plant picker. if (refinement === 'store_bought') { return [ { id: 'fruit', title: 'Fruit photo (before you eat)', requirement: 'Required', required: true, helper: 'Capture the fruit (sticker even better) to confirm the right fruit. This is the priority β€” a seed alone never gets a hit.', emptyText: 'Fruit photo' }, { id: 'whole', title: 'Seed close-up (if you have it)', requirement: 'Optional', required: false, helper: 'Show the seed clearly β€” shape, size, and color. Only if you have the seed.', emptyText: 'Seed close-up photo' }, ]; } if (refinement === 'wild') { return [ { id: 'fruit', title: 'Fruit photo (before you eat)', requirement: 'Required', required: true, helper: 'Capture the fruit (sticker even better) to confirm the right fruit. This is the priority β€” a seed alone never gets a hit.', emptyText: 'Fruit photo' }, { id: 'whole', title: 'Seed close-up (if you have it)', requirement: 'Optional', required: false, helper: 'Show the seed clearly β€” shape, size, and color. Only if you have the seed.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole plant it was growing on', requirement: 'Optional', required: false, helper: 'Show the whole plant the fruit was growing on (shrub, vine, bush, or tree).', emptyText: 'Whole plant photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the plant.', emptyText: 'Leaf close-up photo' }, { id: 'plant', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'A flower close-up helps Pixie confirm the plant.', emptyText: 'Flower close-up photo' }, { id: 'bark', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark or stem texture helps Pixie confirm the plant.', emptyText: 'Bark close-up photo' }, ]; } // 'unsure' or no refinement β€” fruit + seed + the FULL plant picker // (Phase 1E, 2026-09-07): if found wild and not sure, offer the whole // plant picker, not an all-in-one photo box. return [ { id: 'fruit', title: 'Fruit photo (before you eat)', requirement: 'Required', required: true, helper: 'Capture the fruit (sticker even better) to confirm the right fruit. This is the priority β€” a seed alone never gets a hit.', emptyText: 'Fruit photo' }, { id: 'whole', title: 'Seed close-up (if you have it)', requirement: 'Optional', required: false, helper: 'Show the seed clearly β€” shape, size, and color. Only if you have the seed.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole plant it was growing on', requirement: 'Optional', required: false, helper: 'Show the whole plant the fruit was growing on (shrub, vine, bush, or tree).', emptyText: 'Whole plant photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the plant.', emptyText: 'Leaf close-up photo' }, { id: 'plant', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'A flower close-up helps Pixie confirm the plant.', emptyText: 'Flower close-up photo' }, { id: 'bark', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark or stem texture helps Pixie confirm the plant.', emptyText: 'Bark close-up photo' }, ]; case 'pod': // Found pod + the plant it came from (Phase 1B, 2026-09-07): a pod can be // from a shrub/herb/vine, NOT just a tree. Phase 1C (2026-09-07): the // refinement chip now loads a CUSTOM intake package per plant type, with // different photo types + language. 'unsure' (or no refinement) keeps the // vague full-choices package. if (refinement === 'tree') { return [ { id: 'whole', title: 'Pod photo', requirement: 'Required', required: true, helper: 'Capture the pod clearly β€” shape, size, and texture.', emptyText: 'Pod photo' }, { id: 'leaf', title: 'Whole tree it came from', requirement: 'Optional', required: false, helper: 'Show the whole tree it fell from to help confirm the pod matches.', emptyText: 'Whole tree photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the tree.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark texture helps Pixie confirm the tree.', emptyText: 'Bark close-up photo' }, { id: 'bark', title: 'Flower / pod', requirement: 'Optional', required: false, helper: 'A flower or pod on the tree helps confirm it.', emptyText: 'Flower / pod photo' }, ]; } if (refinement === 'shrub') { return [ { id: 'whole', title: 'Pod photo', requirement: 'Required', required: true, helper: 'Capture the pod clearly β€” shape, size, and texture.', emptyText: 'Pod photo' }, { id: 'leaf', title: 'Whole shrub it came from', requirement: 'Optional', required: false, helper: 'Show the whole shrub it came from to help confirm the pod matches.', emptyText: 'Whole shrub photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the shrub.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower / pod', requirement: 'Optional', required: false, helper: 'A flower or pod on the shrub helps confirm it.', emptyText: 'Flower / pod photo' }, ]; } if (refinement === 'herb_vine') { return [ { id: 'whole', title: 'Pod photo', requirement: 'Required', required: true, helper: 'Capture the pod clearly β€” shape, size, and texture.', emptyText: 'Pod photo' }, { id: 'leaf', title: 'Whole herb / vine it came from', requirement: 'Optional', required: false, helper: 'Show the whole herb or vine it came from to help confirm the pod matches.', emptyText: 'Whole herb / vine photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the herb or vine.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower / pod', requirement: 'Optional', required: false, helper: 'A flower or pod on the herb or vine helps confirm it.', emptyText: 'Flower / pod photo' }, ]; } // 'unsure' or no refinement β€” keep the vague full-choices package. return [ { id: 'whole', title: 'Pod photo', requirement: 'Required', required: true, helper: 'Capture the pod clearly β€” shape, size, and texture.', emptyText: 'Pod photo' }, { id: 'leaf', title: 'Whole plant it came from', requirement: 'Optional', required: false, helper: 'Show the plant it came from (whole plant, leaf, flower, or bark) to help confirm the pod matches.', emptyText: 'Whole plant photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the plant.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower / pod', requirement: 'Optional', required: false, helper: 'A flower or pod on the plant helps confirm it.', emptyText: 'Flower / pod photo' }, { id: 'bark', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark or stem texture helps Pixie confirm the plant.', emptyText: 'Bark close-up photo' }, ]; case 'wild': // Unknown wild seed β€” best-effort, no guarantee. Phase 1C (2026-09-07): // when the seed was ATTACHED to a plant, offer plant photos (whole plant, // leaf, flower, bark) in addition to the seed. Phase 1D (2026-09-07): the // tree/bush/vine plant-type picker loads a SPECIFIC photo package per type // (not the same generic one). 'loose' / 'unsure' keep the seed close-up. if (refinement === 'attached') { if (plantType === 'tree') { return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole tree it was attached to', requirement: 'Optional', required: false, helper: 'Show the whole tree the seed was attached to.', emptyText: 'Whole tree photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the tree.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark texture helps Pixie confirm the tree.', emptyText: 'Bark close-up photo' }, { id: 'bark', title: 'Flower / pod', requirement: 'Optional', required: false, helper: 'A flower or pod on the tree helps confirm it.', emptyText: 'Flower / pod photo' }, ]; } if (plantType === 'bush') { return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole bush it was attached to', requirement: 'Optional', required: false, helper: 'Show the whole bush the seed was attached to.', emptyText: 'Whole bush photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the bush.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'A flower close-up helps Pixie confirm the bush.', emptyText: 'Flower close-up photo' }, ]; } if (plantType === 'vine') { return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole vine it was attached to', requirement: 'Optional', required: false, helper: 'Show the whole vine the seed was attached to.', emptyText: 'Whole vine photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the vine.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'A flower close-up helps Pixie confirm the vine.', emptyText: 'Flower close-up photo' }, ]; } // 'unsure' plant type β€” generic plant photos. return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Whole plant it was attached to', requirement: 'Optional', required: false, helper: 'Show the whole plant the seed was attached to.', emptyText: 'Whole plant photo' }, { id: 'flower', title: 'Leaf close-up', requirement: 'Optional', required: false, helper: 'A leaf close-up helps Pixie confirm the plant.', emptyText: 'Leaf close-up photo' }, { id: 'fruit', title: 'Flower close-up', requirement: 'Optional', required: false, helper: 'A flower close-up helps Pixie confirm the plant.', emptyText: 'Flower close-up photo' }, { id: 'bark', title: 'Bark close-up', requirement: 'Optional', required: false, helper: 'Bark or stem texture helps Pixie confirm the plant.', emptyText: 'Bark close-up photo' }, ]; } if (refinement === 'unsure' || refinement === 'loose') { // Phase 1E (2026-09-07): both 'loose' and 'unsure' get two seed angles // (required + optional) AND one optional "where it was found" habitat // photo β€” the spot may hold useful information. return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Another angle', requirement: 'Optional', required: false, helper: 'A second angle helps Pixie extract what it can.', emptyText: 'Second angle photo' }, { id: 'habitat', title: 'Where it was found', requirement: 'Optional', required: false, helper: 'A photo of the spot where you found it (ground, leaf litter, tree base) may help Pixie.', emptyText: 'Where it was found photo' }, ]; } return [ { id: 'whole', title: 'Seed close-up', requirement: 'Required', required: true, helper: 'Show the seed clearly β€” shape, size, and color.', emptyText: 'Seed close-up photo' }, { id: 'leaf', title: 'Another angle', requirement: 'Optional', required: false, helper: 'A second angle helps Pixie extract what it can.', emptyText: 'Second angle photo' }, { id: 'habitat', title: 'Where it was found', requirement: 'Optional', required: false, helper: 'A photo of the spot where you found it (ground, leaf litter, tree base) may help Pixie.', emptyText: 'Where it was found photo' }, ]; default: return [ { id: 'whole', title: 'Seed photo', requirement: 'Required', required: true, helper: 'Capture the seed clearly.', emptyText: 'Seed photo' }, ]; } } const initialPhotoSlots: Record = { whole: null, leaf: null, flower: null, fruit: null, bark: null, cutting: null, plant: null, habitat: null, }; const loadingCopy = [ 'Pixie is studying your plant...', 'Checking leaf shape...', 'Comparing possible matches...', ]; /** Seed-scan loading copy (Bekky, 2026-09-08): Pixie isn't examining leaves β€” * she's thinking about the seed itself, its type, and how to grow it. */ const seedLoadingCopy = [ 'Pixie is studying your seed...', 'Checking the seed type and how to grow it...', 'Thinking about germination and care...', ]; /** Human-readable label for the fungus kingdom classification (Bekky, 2026-08-25). */ function fungusKingdomLabel(kingdom: 'mushroom' | 'bracket_fungus' | 'slime_mold' | 'lichen' | 'moss' | 'plant' | 'other'): string { switch (kingdom) { case 'mushroom': return 'πŸ„ Mushroom (cap and stem)'; case 'bracket_fungus': return 'πŸͺ΅ Bracket / shelf fungus'; case 'slime_mold': return '🫠 Slime mold'; case 'lichen': return 'πŸͺ¨ Lichen'; case 'moss': return '🌿 Moss'; case 'plant': return '🌱 A plant (not a fungus)'; case 'other': return '❓ Other organism'; default: return 'Unknown'; } } /** Human-readable label for fungus edibility. Always paired with the disclaimer. */ function fungusEdibilityLabel(edibility: 'edible' | 'poisonous' | 'unknown'): string { if (edibility === 'edible') return 'Reported edible β€” but NEVER rely on this. Confirm with an expert before consuming.'; if (edibility === 'poisonous') return 'Poisonous β€” do not eat.'; return 'Edibility unknown β€” do not eat without expert confirmation.'; } /** Phase 2 (2026-09-07): parse a Google Maps share link into lat/lng coords. * Handles the common formats: * - https://maps.app.goo.gl/ (can't resolve without a fetch β€” returns null) * - https://www.google.com/maps/@lat,lng,zoom * - https://www.google.com/maps/place/.../@lat,lng,zoom * - https://maps.google.com/?q=lat,lng * - https://www.google.com/maps?q=lat,lng * Returns { latitude, longitude } or null if no coords found. */ function parseGoogleMapsLink(url: string): { latitude: number; longitude: number } | null { if (!url) return null; const trimmed = url.trim(); // @lat,lng,zoom (the @-form used by share links) const atMatch = trimmed.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); if (atMatch) { const lat = parseFloat(atMatch[1]); const lng = parseFloat(atMatch[2]); if (Number.isFinite(lat) && Number.isFinite(lng)) return { latitude: lat, longitude: lng }; } // ?q=lat,lng or ?q=lat,lng,zoom const qMatch = trimmed.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); if (qMatch) { const lat = parseFloat(qMatch[1]); const lng = parseFloat(qMatch[2]); if (Number.isFinite(lat) && Number.isFinite(lng)) return { latitude: lat, longitude: lng }; } // /maps/place/.../@lat,lng,zoom (already covered by @-form) return null; } function getScanPhotoSlots(plantType: PlantType, scanMode?: 'plant' | 'fungus' | 'seed'): ScanPhotoSlotDefinition[] { if (scanMode === 'fungus') return fungusPhotoSlots; if (scanMode === 'seed') return seedPhotoSlotsFor('packet'); if (plantType === 'tree') return treePhotoSlots; if (plantType === 'garden' || plantType === 'crop') return gardenAndCropPhotoSlots; if (plantType === 'cutting') return cuttingPhotoSlots; return generalPlantPhotoSlots; } function getPhotoRequirementCopy(plantType: PlantType, scanMode?: 'plant' | 'fungus' | 'seed') { if (scanMode === 'fungus') return 'Add a photo of the fruiting body (cap and stem) and a close-up of the gills/pores under the cap. The base and what it grows on are optional but helpful.'; if (scanMode === 'seed') return 'Add a clear photo of the seed (or packet). The exact slots depend on what you are scanning β€” pick the closest match above.'; if (plantType === 'tree') return 'Add a whole tree or branch photo and a leaf close-up. Bark and flower or fruit are optional.'; if (plantType === 'garden' || plantType === 'crop') return 'Add a whole plant photo and a leaf close-up. Flower and fruit are optional.'; if (plantType === 'cutting') return 'Add a clear cutting photo. Leaf close-up is optional.'; return 'Add a whole plant photo and a leaf close-up. Flower is optional.'; } function getPhotoValidationCopy(plantType: PlantType, scanMode?: 'plant' | 'fungus' | 'seed') { if (scanMode === 'fungus') return 'Add a photo of the fruiting body and the underside/gills before identifying.'; if (scanMode === 'seed') return 'Add a clear photo of the seed (or packet) before identifying.'; if (plantType === 'tree') return 'Add a whole tree or branch photo and a leaf close-up before identifying.'; if (plantType === 'garden' || plantType === 'crop') return 'Add a whole plant photo and a leaf close-up before identifying.'; if (plantType === 'cutting') return 'Add a clear cutting photo before identifying.'; return 'Add a whole plant photo and a leaf close-up before identifying.'; } async function addPlantReference(result: PlantIdentificationResult): Promise { const topSuggestion = result.topSuggestion; const scientificName = topSuggestion?.scientificName || result.scientificName; const commonName = topSuggestion?.commonName || result.commonNames[0]; const displayName = topSuggestion ? formatSuggestionName(topSuggestion) : result.userProvidedName; try { const reference = await lookupPlantReference({ scientificName, commonName, displayName, }); if (reference) { console.log('[PlantModeScreen] Wikipedia reference found:', reference.url); return { ...result, referenceUrl: reference.url, referenceTitle: reference.title, referenceSource: reference.source, }; } } catch (err) { console.warn('[PlantModeScreen] Wikipedia lookup failed:', err); } // Fallback: construct a Wikipedia URL from the scientific or common name const fallbackName = scientificName || commonName || displayName; if (fallbackName) { const fallbackUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(fallbackName.replace(/\s+/g, '_'))}`; console.log('[PlantModeScreen] Using fallback Wikipedia URL:', fallbackUrl); return { ...result, referenceUrl: fallbackUrl, referenceTitle: fallbackName, referenceSource: 'Wikipedia', }; } return result; } export function PlantModeScreen({ onBack, onSavePlant, onUpdatePlant, onViewPlant, onSaveWishlist, onSaveFieldDiaryEntry, wishlistVisibility, initialImage, savedGpsPrecision, rememberGpsPrecision, savedApproximateLocationText, carePreferences, scanMode }: Props) { const plantCommitInFlightRef = useRef(false); const plantMediaPersistInFlightRef = useRef(false); const plantAnalysisEpochRef = useRef(0); useEffect(() => () => { plantAnalysisEpochRef.current += 1; }, []); // audit-fix: invalidate pending async work on unmount const insets = useSafeAreaInsets(); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const [photoSlots, setPhotoSlots] = useState>(initialPhotoSlots); const [optionalKnownName, setOptionalKnownName] = useState(''); const [plantType, setPlantType] = useState('unsure'); // Which organism the user is scanning in Fungus Mode (Bekky, 2026-08-25): // mushroom / bracket / lichen / moss / other. Drives the photo slots + prompt. const [fungusKind, setFungusKind] = useState('mushroom'); // Which seed intake path the user is on in Seed Mode (Bekky, 2026-09-07, // seed-intake feature): packet / fruit / pod / wild. Drives the photo slots + prompt. const [seedKind, setSeedKind] = useState('packet'); // Forced refinement of the seed path (Phase 1B, 2026-09-07): the user must // pick a refinement chip before a fruit/pod/wild scan can proceed. Reset to // null whenever the main path chip changes. const [seedRefinement, setSeedRefinement] = useState(null); // Free-text plant context for the unknown-wild-seed path (Phase 1B): what // plant the seed was attached to / what the plant looked like. const [seedPlantContext, setSeedPlantContext] = useState(''); // Plant type refinement for the unknown-wild-seed 'attached' path (Phase 1C, // 2026-09-07): tree / bush / vine / unsure. A third chip row shown only when // seedKind === 'wild' && seedRefinement === 'attached'. Reset when the main // path or refinement changes. const [seedPlantType, setSeedPlantType] = useState(null); // Free-text surrounding-plant context for the unknown-wild-seed path (Phase // 1C, 2026-09-07): other plants the user saw growing around the seed. const [seedSurroundingPlants, setSeedSurroundingPlants] = useState(''); const [locationContext, setLocationContext] = useState('unsure'); const [approximateLocationText, setApproximateLocationText] = useState(savedApproximateLocationText || ''); const [notes, setNotes] = useState(''); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [validation, setValidation] = useState(null); const [locationMenuOpen, setLocationMenuOpen] = useState(false); // Map saved GpsPrecision to LocationSource: // 'rough' β†’ 'general', 'precise' β†’ 'precise', 'skip' β†’ 'manual' (user enters ZIP manually) const [locationSource, setLocationSource] = useState( rememberGpsPrecision ? (savedGpsPrecision === 'rough' ? 'general' : savedGpsPrecision === 'precise' ? 'precise' : 'manual') : 'general' ); const [locationSourceMenuOpen, setLocationSourceMenuOpen] = useState(false); // Phase 2 (2026-09-07): map pin-drop + Google Maps link location methods. const [mapPin, setMapPin] = useState<{ latitude: number; longitude: number } | null>(null); const [mapRegion, setMapRegion] = useState<{ latitude: number; longitude: number; latitudeDelta: number; longitudeDelta: number } | null>(null); const mapRef = useRef(null); const [mapSearchQuery, setMapSearchQuery] = useState(''); const [mapSearching, setMapSearching] = useState(false); const [mapSearchError, setMapSearchError] = useState(null); // Phase 2g (2026-09-07): multiple search matches β€” let the user pick the right one. const [mapSearchResults, setMapSearchResults] = useState<{ latitude: number; longitude: number; label: string }[] | null>(null); const [savedProfileId, setSavedProfileId] = useState(null); // True when the saved profile is a CUTTING (added via the cutting modal), // false when it's the whole plant. Drives the footer button labels so the // UI doesn't show "Add Cutting" after a cutting was already added (Bekky, 2026-08-20). const [savedIsCutting, setSavedIsCutting] = useState(false); // Mirrors savedProfileId so async callbacks (fireEnrichment) read the LATEST // value, not the stale closure captured when the scan completed (when it was null). const savedProfileIdRef = useRef(null); useEffect(() => { savedProfileIdRef.current = savedProfileId; }, [savedProfileId]); const [savedScanId, setSavedScanId] = useState(null); const [fieldDiarySaved, setFieldDiarySaved] = useState(false); // Synchronous fire-once guard for the Field Diary save (Bekky, 2026-09-03): // the async addToFieldDiary only sets fieldDiarySaved at the END (after // persistSelectedPhotos), so two rapid taps both pass the state guard and // create duplicate entries. This ref is set synchronously on entry. const fieldDiarySavingRef = useRef(false); const [actionNotice, setActionNotice] = useState(null); const [loadingCopyIndex, setLoadingCopyIndex] = useState(0); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const cameraRef = useRef(null); // Propagation and enrichment state const [propagationInfo, setPropagationInfo] = useState(null); const [enrichmentData, setEnrichmentData] = useState(null); const [enrichmentLoading, setEnrichmentLoading] = useState(false); // Scan-screen cutting modal (Bekky, 2026-08-20): tapping "See Cutting Options" // fetches propagation on demand (the scan no longer returns it), then opens // the cutting modal. Weather is fetched live for a contextual cutting warning. const [showCuttingModal, setShowCuttingModal] = useState(false); const [cuttingFetchLoading, setCuttingFetchLoading] = useState(false); const [cuttingWeather, setCuttingWeather] = useState(null); // EPHEMERAL weather-enriched cutting guidance (Bekky, 2026-08-20): shown on // the cutting card, then DISCARDED. Kept SEPARATE from `propagationInfo` (the // stable species-facts) so the weather-aware version is never persisted onto // the plant. The plant detail keeps stable facts; this is just-for-the-card. const [cuttingPropagationInfo, setCuttingPropagationInfo] = useState(null); // GPS captured at scan time (for the live weather line in the cutting modal). const lastGpsRef = useRef<{ latitude: number; longitude: number } | null>(null); // Result ScrollView ref (Bekky, 2026-09-08): scroll to top + center after // Start Growing so the user sees the planting guide. const resultScrollRef = useRef(null); const [previewUri, setPreviewUri] = useState(null); // Pet-safety warning modal (light-themed PixieAlert, not native Alert which renders dark on Xiaomi). const [petWarning, setPetWarning] = useState<{ title: string; message: string } | null>(null); // ---- Seed tiered AI-call state (Phase 3 continuation, 2026-09-08) ---- // "Save to Seed Bank" runs a rich storage+prep call; "Start Growing" runs a // step-by-step planting call. Both are context-aware (location + season + // weather). Wishlist + Field Diary do NOT run a new call (Bekky, 2026-09-08). const [seedBankSaving, setSeedBankSaving] = useState(false); const [seedBankPrep, setSeedBankPrep] = useState(null); const [seedBankSaved, setSeedBankSaved] = useState(false); const [growingLoading, setGrowingLoading] = useState(false); const [plantingGuide, setPlantingGuide] = useState(null); const [growingStarted, setGrowingStarted] = useState(false); // The seed plant profile created by "Start Growing" (so we can view it). const [grownPlantId, setGrownPlantId] = useState(null); // Fire-once guards so rapid taps don't double-run the AI calls. const seedBankSavingRef = useRef(false); const growingRef = useRef(false); useEffect(() => { if (initialImage) { setPhotoSlots(prev => ({ ...prev, whole: { uri: initialImage } })); } }, []); const isFungusScan = scanMode === 'fungus'; const isSeedScan = scanMode === 'seed'; // Phase 1D (2026-09-07): gate the photo form until the required chips are // chosen, so the user never sees a form that changes after they pick a later // chip. packet needs no refinement; fruit/pod need the refinement chip; wild // needs the refinement chip AND (if 'attached') the plant-type chip. const seedChipsComplete = !isSeedScan || seedKind === 'packet' || (!!seedRefinement && (seedKind !== 'wild' || seedRefinement !== 'attached' || !!seedPlantType)); // Phase 2i (2026-09-07): the found-location map only makes sense for found/ // wild finds. Hide it for seed packet and store-bought fruit (no map logic). const showFoundLocationMap = isSeedScan && seedKind !== 'packet' && !(seedKind === 'fruit' && seedRefinement === 'store_bought'); const visiblePhotoSlots = isSeedScan ? seedPhotoSlotsFor(seedKind, seedRefinement ?? undefined, seedPlantType ?? undefined) : isFungusScan ? fungusPhotoSlotsFor(fungusKind) : getScanPhotoSlots(plantType); const selectedPhotos = visiblePhotoSlots .map(slot => { const photo = photoSlots[slot.id]; return photo ? { slot: slot.id, photo } : null; }) .filter((photo): photo is { slot: ScanPhotoSlotId; photo: ScanPhotoState } => Boolean(photo)); const primaryPhotoUri = selectedPhotos[0]?.photo.uri || null; // Persist the selected scan photos to permanent storage (Bekky, 2026-09-02): // the image-picker/camera cache URIs get cleaned by the OS β†’ blank green // squares. persistPhoto copies each to pixiesprout-photos/ and returns the // permanent URI. Falls back to the original URI if persistence fails. const persistSelectedPhotos = async (): Promise<{ uri: string }[]> => { const uris = selectedPhotos.map(sp => sp.photo.uri).filter(Boolean); const out: { uri: string }[] = []; for (const uri of uris) { try { const record = await persistPhoto(uri, { photoType: 'plant_id' }); out.push({ uri: record.activeUri }); } catch { out.push({ uri }); } } return out; }; const requiredPhotosReady = visiblePhotoSlots .filter(slot => slot.required) .every(slot => Boolean(photoSlots[slot.id])); const photoRequirementCopy = getPhotoRequirementCopy(plantType, scanMode); const photoValidationCopy = getPhotoValidationCopy(plantType, scanMode); // Location context is ALWAYS shown for fungus and seed β€” it drives the ownership // gate (home/garden_yard = addable/cultivable; park/trail/public = info-only). const showLocationContext = isFungusScan || isSeedScan || plantType !== 'houseplant'; const effectiveLocationContext: LocationContext = plantType === 'houseplant' && !isFungusScan && !isSeedScan ? 'indoor' : locationContext; const resultFooterBottom = Math.max(insets.bottom, 48) + screenWidth * 0.95 * (443 / 1876) + 8; // Footer now holds 5 buttons in the owned-plant branch (Add to Garden / // See Cutting / Add to Wishlist / πŸ“ Save to Field Diary / Scan Again) after // the Field Diary button was added for every scan (Bekky, 2026-08-26). 260px // reserves enough scroll padding so the footer doesn't clip the card above. const resultFooterHeight = 260; const resultCardTopOffset = 18; const resultCardMinHeight = Math.max(372, screenHeight - resultFooterBottom - resultFooterHeight - 55 - resultCardTopOffset); const selectedLocationLabel = plantType === 'houseplant' ? 'Indoor' : locationContexts.find(choice => choice.value === locationContext)?.label || 'Unsure'; useEffect(() => { if (!loading) { setLoadingCopyIndex(0); return undefined; } const timer = setInterval(() => { setLoadingCopyIndex(current => (current + 1) % loadingCopy.length); }, 2200); return () => clearInterval(timer); }, [loading]); const selectPlantType = (nextPlantType: PlantType) => { setPlantType(nextPlantType); setLocationMenuOpen(false); if (nextPlantType === 'houseplant') { setLocationContext('indoor'); } else if (locationContext === 'indoor') { setLocationContext('unsure'); } }; const clearResultState = () => { setPreviewUri(null); // audit-fix: clear media state during reset setPhotoSlots(initialPhotoSlots); // audit-fix: clear media state during reset plantAnalysisEpochRef.current += 1; // audit-fix: invalidate pending plant analysis setResult(null); setSavedProfileId(null); setSavedScanId(null); setActionNotice(null); setPropagationInfo(null); setEnrichmentData(null); setEnrichmentLoading(false); }; const applyPickedPhoto = (slot: ScanPhotoSlotId, response: ImagePicker.ImagePickerResult | null | undefined) => { if (!response || response.canceled) return true; const selectedAsset = Array.isArray(response.assets) ? response.assets[0] : null; if (selectedAsset?.uri) { setPhotoSlots(current => ({ ...current, [slot]: { uri: selectedAsset.uri, width: selectedAsset.width, height: selectedAsset.height, }, })); clearResultState(); return true; } return false; }; const choosePhoto = async (slot: ScanPhotoSlotId) => { setError(null); setValidation(null); try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { setError('Photo library access is needed to choose a plant photo.'); return; } const response = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: false, exif: false, quality: 1, }); if (response.canceled) return; if (!applyPickedPhoto(slot, response)) { setError('That photo could not be loaded. Please choose another one.'); } } catch { setError('Photo selection is unavailable right now. Please try again.'); } }; const takePhoto = async (slot: ScanPhotoSlotId) => { setError(null); setValidation(null); const nativeCameraPermission = await ImagePicker.requestCameraPermissionsAsync(); if (!nativeCameraPermission.granted) { setError('Camera access is needed to take a plant photo.'); return; } try { const response = await ImagePicker.launchCameraAsync({ allowsEditing: false, exif: false, quality: 0.45, }); if (!applyPickedPhoto(slot, response)) { setError('That camera photo could not be loaded. Please try again.'); } } catch { setError('Camera is unavailable right now. Please choose a photo instead.'); } }; // Phase 2d (2026-09-07): move the map to a coordinate + drop a pin, using // animateToRegion so the map visibly scrolls to the pin (a controlled `region` // prop alone fights onRegionChangeComplete and never recenters). const moveMapTo = (lat: number, lng: number) => { setMapPin({ latitude: lat, longitude: lng }); const region = { latitude: lat, longitude: lng, latitudeDelta: 0.05, longitudeDelta: 0.05 }; setMapRegion(region); mapRef.current?.animateToRegion(region, 600); }; // Phase 2f (2026-09-07): geocode a map search query, biased to the user's // current location. The native geocoder returns the first global match, so we // append the user's city/country to the query (e.g. "Golden Valley" β†’ // "Golden Valley, Da Lat, Vietnam") to prefer local places. If the user types // a full "City, State" the appended hint is redundant but harmless. const searchMapLocation = async () => { const q = mapSearchQuery.trim(); if (!q) return; setMapSearching(true); setMapSearchError(null); try { // Build a locality hint from the user's current position (best-effort). let localityHint = ''; try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status === 'granted') { const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); const place = await Location.reverseGeocodeAsync({ latitude: loc.coords.latitude, longitude: loc.coords.longitude, }); if (place && place.length > 0) { const p = place[0]; const city = p.city || p.district || p.subregion || ''; const country = p.country || ''; localityHint = [city, country].filter(Boolean).join(', '); } } } catch { // No location permission / GPS β€” search without a locality hint. } const searchQuery = localityHint ? `${q}, ${localityHint}` : q; const results = await Location.geocodeAsync(searchQuery); if (results && results.length > 0) { // Phase 2g (2026-09-07): if there are multiple matches, show them as a // tappable list so the user can pick the right one (e.g. two "Golden // Valley" places in Da Lat). Reverse-geocode each to build a useful // label (name + city) so the user can tell them apart. If only one, // drop the pin directly. const valid = results .filter((r) => Number.isFinite(r.latitude) && Number.isFinite(r.longitude)) .map((r) => ({ latitude: r.latitude, longitude: r.longitude })); if (valid.length === 0) { setMapSearchError('Couldn\'t find that place. Try a more specific name.'); } else if (valid.length === 1) { setMapSearchResults(null); moveMapTo(valid[0].latitude, valid[0].longitude); } else { // Build labels by reverse-geocoding each match (best-effort). const labeled: { latitude: number; longitude: number; label: string }[] = []; for (const v of valid) { let label = `${v.latitude.toFixed(4)}, ${v.longitude.toFixed(4)}`; try { const place = await Location.reverseGeocodeAsync({ latitude: v.latitude, longitude: v.longitude }); if (place && place.length > 0) { const p = place[0]; const name = p.name || p.street || ''; const city = p.city || p.district || p.subregion || ''; const country = p.country || ''; label = [name, city, country].filter(Boolean).join(', ') || label; } } catch { // keep the coords label } labeled.push({ latitude: v.latitude, longitude: v.longitude, label }); } setMapSearchResults(labeled); } } else { setMapSearchError('Couldn\'t find that place. Try a more specific name.'); } } catch { setMapSearchError('Search is unavailable right now. Try again or drop a pin manually.'); } finally { setMapSearching(false); } }; // Phase 2c (2026-09-07): default the map to the user's current location (or // fall back to a broad country view) instead of opening on the US. const centerMapOnUser = async () => { try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status === 'granted') { const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); const lat = loc.coords.latitude; const lng = loc.coords.longitude; const region = { latitude: lat, longitude: lng, latitudeDelta: 0.1, longitudeDelta: 0.1 }; setMapRegion(region); mapRef.current?.animateToRegion(region, 600); lastGpsRef.current = { latitude: lat, longitude: lng }; } } catch { // Permission denied or GPS failed β€” leave the map on the default region. } }; const submit = async () => { setError(null); setValidation(null); if (!requiredPhotosReady) { setValidation(photoValidationCopy); return; } if (!primaryPhotoUri) { setValidation(photoValidationCopy); return; } // Phase 1B (2026-09-07) β€” forced refinement: a fruit/pod/wild scan cannot // proceed until the user picks a refinement chip, so the AI has the context // it needs instead of guessing. packet needs no refinement (the packet has // the info). Phase 1D (2026-09-07): wild+attached also needs the plant-type // chip before scanning. if (isSeedScan && seedKind !== 'packet' && !seedRefinement) { setValidation('Please pick a refinement so Pixie knows more about your find.'); return; } if (isSeedScan && seedKind === 'wild' && seedRefinement === 'attached' && !seedPlantType) { setValidation('Please pick what kind of plant the seed was attached to.'); return; } // Phase 1B (2026-09-07) β€” fruit seed: the fruit NAME is mandatory if no // fruit photo is provided (the AI can ID it from a photo, but without a // photo the name is required). The fruit name is the optionalKnownName field. if (isSeedScan && seedKind === 'fruit' && !photoSlots.fruit && !optionalKnownName.trim()) { setValidation('Enter the fruit name (or add a fruit photo) so Pixie knows what fruit it is.'); return; } // Phase 2h (2026-09-07): the found-location is now just the embedded map. // If the user dropped a pin, use it as the found location. let gpsLocation: PlantIdentificationInput['gpsLocation'] | undefined; if (mapPin) { gpsLocation = { latitude: mapPin.latitude, longitude: mapPin.longitude }; lastGpsRef.current = { latitude: mapPin.latitude, longitude: mapPin.longitude }; } // Gather location data based on source selection let effectiveApproximateText = approximateLocationText.trim() || undefined; if (locationSource === 'precise') { try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status === 'granted') { const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High, }); gpsLocation = { latitude: loc.coords.latitude, longitude: loc.coords.longitude, heading: loc.coords.heading ?? undefined, altitude: loc.coords.altitude ?? undefined, }; lastGpsRef.current = { latitude: loc.coords.latitude, longitude: loc.coords.longitude }; } } catch { // GPS failed β€” fall through silently, still use other location info } } // If no manual text but we have GPS coords, use those if (!effectiveApproximateText && gpsLocation) { effectiveApproximateText = `${gpsLocation.latitude.toFixed(4)},${gpsLocation.longitude.toFixed(4)}`; } const input: PlantIdentificationInput = { photoUri: primaryPhotoUri, photoUris: selectedPhotos.map(item => item.photo.uri), photos: selectedPhotos.map(item => ({ role: item.slot, uri: item.photo.uri, width: item.photo.width, height: item.photo.height, })), mode: scanMode === 'seed' ? 'seed' : scanMode === 'fungus' ? 'fungus' : 'plant', fungusKind: scanMode === 'fungus' ? fungusKind : undefined, seedKind: scanMode === 'seed' ? seedKind : undefined, seedRefinement: scanMode === 'seed' ? seedRefinement ?? undefined : undefined, seedPlantContext: scanMode === 'seed' ? seedPlantContext.trim() || undefined : undefined, seedPlantType: scanMode === 'seed' ? seedPlantType ?? undefined : undefined, seedSurroundingPlants: scanMode === 'seed' ? seedSurroundingPlants.trim() || undefined : undefined, optionalKnownName: optionalKnownName.trim() || undefined, plantType, locationContext: effectiveLocationContext, approximateLocationText: effectiveApproximateText, gpsLocation, notes: notes.trim() || undefined, carePreferences, }; setLoading(true); try { const identification = await identifyPlant(input); const identificationWithReference = await addPlantReference(identification); // Propagation data is now included in the scan result from AI if (identificationWithReference.propagation) { const rawProp = identificationWithReference.propagation; setPropagationInfo({ canPropagate: rawProp.canPropagate ?? false, methods: (rawProp.methods || []).map(m => ({ method: m.method as import('../types/propagation').RootingMethod, difficulty: m.difficulty as import('../types/propagation').PropagationDifficulty, instructions: m.instructions || [], timeframe: m.timeframe, successRate: m.successRate as import('../types/propagation').SuccessRate, risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (rawProp.notRecommended || []).map(m => ({ method: m.method as import('../types/propagation').RootingMethod, reason: m.reason, })), generalNote: rawProp.generalNote, }); } setResult(identificationWithReference); setSavedProfileId(null); setSavedScanId(null); setActionNotice(null); // NOTE (Bekky, 2026-08-20): stable enrichment (bio + propagation) now runs // AFTER the plant is added, not at scan time. The scan page's only // propagation action is "take a cutting or not" (ephemeral, via the cutting // modal); the stable species-facts belong on the plant detail page. } catch (reason) { setError(getIdentificationErrorMessage(reason)); } finally { setLoading(false); } }; const addToGarden = async () => { if (plantCommitInFlightRef.current) return; plantCommitInFlightRef.current = true; try { if (savedProfileId) { onViewPlant(savedProfileId); return; } // Non-blocking pet-safety notice β€” only when the user has pet-safe caution ON // and the plant is flagged toxic/caution. Never blocks; "Add anyway" is primary. const petCautionOn = carePreferences?.petSafeCaution === true; const petSafety = result?.petSafety; if (petCautionOn && (petSafety === 'toxic' || petSafety === 'caution')) { setPetWarning({ title: 'Heads up about your pets', message: petSafety === 'toxic' ? 'This plant is toxic to pets. You can still add it β€” just keep it out of reach or in a spot your pets can\'t get to.' : 'This plant may be mildly irritating to pets. You can still add it β€” just keep an eye on them around it.', }); return; } await doAddToGarden(); } finally { plantCommitInFlightRef.current = false; } }; const doAddToGarden = async () => { // Hardened (Bekky, 2026-08-19): the button used to fail SILENTLY if // saveCurrentPlant threw or returned null β€” no feedback at all. Now we // surface a real error so "Add to Garden not working" is never a silent // dead-tap again. setError(null); try { const plantId = await saveCurrentPlant(); if (plantId) { setActionNotice('Added to your Garden.'); // Stable enrichment (bio + propagation) runs AFTER add (Bekky, 2026-08-20). if (result) fireEnrichment(result); } else { setError('Could not add this plant β€” some scan details were missing. Try scanning again with a clear photo.'); } } catch (e) { setError('Could not add this plant right now. Please try again.'); // eslint-disable-next-line no-console console.error('[AddToGarden]', e); } }; // "Add Plant to Garden" after a cutting was already saved (Bekky, 2026-08-20): // save the WHOLE plant alongside the cutting. Forced save (bypasses the // savedProfileId guard) so it works after addAsCuttingToGarden set the id. const addWholePlantAfterCutting = async () => { setError(null); try { const plantId = await saveCurrentPlant(true); if (plantId) { setSavedIsCutting(false); setActionNotice('Plant added to your Garden.'); // Stable enrichment (bio + propagation) runs AFTER add (Bekky, 2026-08-20). if (result) fireEnrichment(result); } else { setError('Could not add this plant β€” some scan details were missing. Try scanning again with a clear photo.'); } } catch (e) { setError('Could not add this plant right now. Please try again.'); // eslint-disable-next-line no-console console.error('[AddWholePlant]', e); } }; const openReference = async () => { if (!result?.referenceUrl) return; try { await Linking.openURL(result.referenceUrl); } catch { setError('That Wikipedia page could not be opened right now.'); } }; const scanAnotherPlant = () => { setPhotoSlots(initialPhotoSlots); setOptionalKnownName(''); setPlantType('unsure'); setFungusKind('mushroom'); setSeedKind('packet'); setSeedRefinement(null); setSeedPlantContext(''); setSeedPlantType(null); setSeedSurroundingPlants(''); setLocationContext('unsure'); setApproximateLocationText(savedApproximateLocationText || ''); setNotes(''); setResult(null); setLoading(false); setError(null); setValidation(null); setLocationMenuOpen(false); setSavedProfileId(null); setSavedIsCutting(false); setSavedScanId(null); setActionNotice(null); }; const saveCurrentPlant = async (force = false) => { if (plantMediaPersistInFlightRef.current) return; plantMediaPersistInFlightRef.current = true; try { if (!primaryPhotoUri || !result) return null; // When force (adding the whole plant after a cutting was saved), allow a // second save. Otherwise guard against double-add (Bekky, 2026-08-20). if (!force && savedProfileId) return savedProfileId; const topSuggestion = result.topSuggestion; if (!topSuggestion) return null; const commonName = topSuggestion.commonName || topSuggestion.name || result.userProvidedName || 'Plant scan'; const scientificName = topSuggestion.scientificName || result.scientificName || commonName; const scanNotes = notes.trim() || result.notes; const now = new Date().toISOString(); const profile: SavedPlantProfile = { id: `plant_scan_${Date.now()}`, photoUri: primaryPhotoUri, commonName, scientificName, confidence: result.confidence, alternatives: Array.isArray(result.suggestions) ? result.suggestions.slice(1, 4).map(formatSuggestionName) : [], scanResultId: result.id, scanProvider: result.provider, scanProviderRequestId: result.providerRequestId, scanSourceUrl: result.sourceUrl, identification: { scanResultId: result.id, provider: result.provider, providerRequestId: result.providerRequestId, identifiedAt: result.createdAt, imageUri: primaryPhotoUri, imageUris: result.imageUris || [primaryPhotoUri], commonName, scientificName, confidence: result.confidence, suggestions: Array.isArray(result.suggestions) ? result.suggestions.slice(0, 4).map(suggestion => ({ name: suggestion.name, commonName: suggestion.commonName, scientificName: suggestion.scientificName, confidence: suggestion.confidence, })) : [], description: result.description || topSuggestion.description, sourceUrl: result.sourceUrl || topSuggestion.sourceUrl, referenceUrl: result.referenceUrl, referenceTitle: result.referenceTitle, referenceSource: result.referenceSource, notes: scanNotes, }, plantType, locationContext: effectiveLocationContext, approximateLocationText: approximateLocationText.trim() || undefined, gpsLocation: lastGpsRef.current || undefined, notes: scanNotes, careSummary: 'No care plan generated from this scan yet.', toxicityWarning: 'Safety and toxicity were not checked by this plant identification scan.', enrichmentData: enrichmentData || undefined, propagationInfo: propagationInfo || (result.propagation ? { canPropagate: result.propagation.canPropagate ?? false, methods: (result.propagation.methods || []).map(m => ({ method: m.method as import('../types/propagation').RootingMethod, difficulty: m.difficulty as import('../types/propagation').PropagationDifficulty, timeframe: m.timeframe, successRate: m.successRate as import('../types/propagation').SuccessRate, instructions: m.instructions || [], risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (result.propagation.notRecommended || []).map(m => ({ method: m.method as import('../types/propagation').RootingMethod, reason: m.reason, })), generalNote: result.propagation.generalNote, } : undefined), scanMode: result.mode === 'fungus' ? 'fungus' : result.mode === 'seed' ? 'seed' : undefined, kingdom: result.kingdom, edibility: result.edibility, cultivable: result.cultivable, substrate: result.substrate, seed: result.mode === 'seed' ? result.seed : undefined, seedKind: result.mode === 'seed' ? seedKind : undefined, createdAt: now, updatedAt: now, source: 'plant_scan', }; onSavePlant(profile); setSavedProfileId(profile.id); setSavedIsCutting(false); const updatedResult: PlantIdentificationResult = { ...result, addedToGarden: true, linkedPlantId: profile.id }; setResult(updatedResult); await savePlantScanResult(updatedResult); setSavedScanId(updatedResult.id); return profile.id; } finally { plantMediaPersistInFlightRef.current = false; } }; const saveScan = async () => { if (!result) return; try { await savePlantScanResult(result); setSavedScanId(result.id); setActionNotice('Scan saved.'); } catch { setError('The scan could not be saved right now. Your result is still on this screen.'); } }; const addToWishlist = async () => { const analysisEpoch = ++plantAnalysisEpochRef.current; // audit-fix: reject stale results if (!result || !primaryPhotoUri) return; const topSuggestion = result.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || result.userProvidedName || 'Plant scan'; // Persist the primary photo to permanent storage (Bekky, 2026-09-02): the // cache URI gets cleaned by the OS β†’ blank green square on the wishlist. let imageUri = primaryPhotoUri; try { const record = await persistPhoto(primaryPhotoUri, { photoType: 'wishlist' }); if (analysisEpoch !== plantAnalysisEpochRef.current) return; imageUri = record.activeUri; } catch { if (analysisEpoch !== plantAnalysisEpochRef.current) return; /* keep cache URI as fallback */ } const wishlistItem: WishlistItem = { id: `wishlist_scan_${Date.now()}`, createdAt: new Date().toISOString(), source: 'scan', imageUri, commonName, scientificName: topSuggestion?.scientificName || result.scientificName, confidence: result.confidence, description: result.description || topSuggestion?.description, notes: notes.trim() || result.notes || undefined, scanResultId: result.id, locationContext: result.locationContext, nearbyCityOrZip: result.nearbyCityOrZip, gpsLocation: lastGpsRef.current || undefined, propagation: result.propagation, wishlistVisibility, // Rich scan data carried through (Bekky, 2026-08-25) so the wishlist detail // is as rich as a garden plant's Identity/About/Wikipedia. taxonomy: topSuggestion?.taxonomy || result.taxonomy, commonNames: result.commonNames?.length ? result.commonNames : topSuggestion?.commonNames, plantType: result.plantType, petSafety: result.petSafety, sourceUrl: topSuggestion?.sourceUrl || result.sourceUrl, referenceUrl: result.referenceUrl, referenceTitle: result.referenceTitle, referenceSource: result.referenceSource, // Fungus scan fields (Bekky, 2026-08-25) β€” carried so the wishlist detail // keeps the kingdom classification + edibility + disclaimer. scanMode: result.mode === 'fungus' ? 'fungus' : result.mode === 'seed' ? 'seed' : undefined, kingdom: result.kingdom, edibility: result.edibility, cultivable: result.cultivable, substrate: result.substrate, seed: result.mode === 'seed' ? result.seed : undefined, seedKind: result.mode === 'seed' ? seedKind : undefined, }; const updatedResult: PlantIdentificationResult = { ...result, addedToWishlist: true }; try { onSaveWishlist(wishlistItem); await savePlantScanResult(updatedResult); if (analysisEpoch !== plantAnalysisEpochRef.current) return; setResult(updatedResult); setSavedScanId(updatedResult.id); setActionNotice('Added to your wishlist.'); } catch { if (analysisEpoch !== plantAnalysisEpochRef.current) return; setError('The wishlist save could not be finished right now. Your result is still on this screen.'); } }; /** Save the scan result to the Field Diary (Bekky, 2026-08-25). Captures ALL * the scan photos (own copies at scan resolution), the identification * richness, and the location (precise coords + place label) into an album. */ const addToFieldDiary = async () => { if (!result || !primaryPhotoUri) return; // Dedup: once saved, block repeat taps so we don't create an infinite run // of identical Field entries (Bekky, 2026-08-26). if (fieldDiarySaved) { haptic.warning(); setActionNotice('Already saved to your Field Diary.'); return; } // Synchronous fire-once guard (Bekky, 2026-09-03): set immediately so a // second rapid tap can't slip past while the async work below is running. if (fieldDiarySavingRef.current) { haptic.warning(); setActionNotice('Already saving to your Field Diary.'); return; } fieldDiarySavingRef.current = true; try { const topSuggestion = result.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || result.userProvidedName || 'Plant scan'; // Persist ALL photos to permanent storage (Bekky, 2026-09-02): the cache // URIs get cleaned by the OS β†’ blank green squares in the field diary. const allPhotos = await persistSelectedPhotos(); if (allPhotos.length === 0 && primaryPhotoUri) { allPhotos.push({ uri: primaryPhotoUri }); } // Precise coords (if GPS captured at scan time) stored PRIVATELY for re-finding. const gps = lastGpsRef.current; const placeLabel = approximateLocationText.trim() || result.nearbyCityOrZip || 'Field find'; const entry: FieldDiaryEntry = { id: `field_entry_${Date.now()}`, createdAt: new Date().toISOString(), commonName, scientificName: topSuggestion?.scientificName || result.scientificName, confidence: result.confidence, description: result.description || topSuggestion?.description, notes: notes.trim() || result.notes || undefined, scanResultId: result.id, photos: allPhotos, coverPhotoUri: primaryPhotoUri, location: { placeLabel, customLabel: undefined, latitude: gps?.latitude, longitude: gps?.longitude, nearbyCityOrZip: result.nearbyCityOrZip, }, taxonomy: topSuggestion?.taxonomy || result.taxonomy, commonNames: result.commonNames?.length ? result.commonNames : topSuggestion?.commonNames, plantType: result.plantType, petSafety: result.petSafety, sourceUrl: topSuggestion?.sourceUrl || result.sourceUrl, referenceUrl: result.referenceUrl, referenceTitle: result.referenceTitle, referenceSource: result.referenceSource, scanMode: result.mode === 'fungus' ? 'fungus' : result.mode === 'seed' ? 'seed' : undefined, kingdom: result.kingdom, edibility: result.edibility, cultivable: result.cultivable, substrate: result.substrate, seed: result.mode === 'seed' ? result.seed : undefined, seedKind: result.mode === 'seed' ? seedKind : undefined, }; onSaveFieldDiaryEntry(entry, gps ? (result.locationContext === 'indoor' ? 'Indoors' : approximateLocationText.trim() || result.nearbyCityOrZip || 'Field find') : 'Field find'); setFieldDiarySaved(true); haptic.success(); setActionNotice('Saved to your Field Diary.'); } catch (e) { // Reset the guard so a transient failure doesn't permanently disable the // button (Bekky, 2026-09-03). setActionNotice('Couldn\'t save to your Field Diary. Please try again.'); } finally { fieldDiarySavingRef.current = false; } }; /** * "Save to Seed Bank" (Phase 3 continuation, 2026-09-08). Runs a rich, * context-aware AI call for storage + prep guidance for THIS seed, then saves * the seed to the Seed Bank (a saved plant with scanMode === 'seed'). The * storage/prep guidance is shown on the result card (ephemeral, not persisted * to the plant β€” the plant detail keeps stable species-facts). */ const saveToSeedBank = async () => { if (!result || !primaryPhotoUri) return; if (seedBankSaved) { haptic.warning(); setActionNotice('Already saved to your Seed Bank.'); return; } if (seedBankSavingRef.current) { haptic.warning(); setActionNotice('Already saving to your Seed Bank.'); return; } seedBankSavingRef.current = true; setSeedBankSaving(true); setError(null); try { const topSuggestion = result.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || result.userProvidedName || 'Seed scan'; const scientificName = topSuggestion?.scientificName || result.scientificName; const seed = result.mode === 'seed' ? result.seed : undefined; // Build the live context (location + season + weather) for the AI call. const approx = (approximateLocationText && approximateLocationText.trim()) || result.nearbyCityOrZip; let weatherLine: string | null = null; const gps = lastGpsRef.current; if (gps) { try { const w = await getWeather(gps.latitude, gps.longitude, 'rough'); if (w) { const days = w.daily || []; const avgHigh = days.length ? days.reduce((s, d) => s + (d.tempMaxC ?? 0), 0) / days.length : null; const avgLow = days.length ? days.reduce((s, d) => s + (d.tempMinC ?? 0), 0) / days.length : null; weatherLine = [ avgHigh != null ? `avg high ${avgHigh.toFixed(0)}Β°C` : '', avgLow != null ? `avg low ${avgLow.toFixed(0)}Β°C` : '', w.humidityPct != null ? `humidity ${w.humidityPct.toFixed(0)}%` : '', ].filter(Boolean).join(', ') || null; } } catch { /* weather best-effort */ } } const prep = await getSeedStoragePrep(commonName, scientificName, seed, { location: approx || null, weatherLine, }).catch(() => null); if (prep) setSeedBankPrep(prep); // Persist the primary photo to permanent storage (same as wishlist). let imageUri = primaryPhotoUri; try { const record = await persistPhoto(primaryPhotoUri, { photoType: 'wishlist' }); imageUri = record.activeUri; } catch { /* keep cache URI as fallback */ } const now = new Date().toISOString(); // Recalcitrant seeds can only be stored a short time (Bekky, 2026-09-08): // compute an expiry date from the storage duration so the user knows when // it must be planted or it's no longer viable. const expiresAt = computeSeedExpiry(seed); const seedProfile: SavedPlantProfile = { id: `seed_bank_${Date.now()}`, photoUri: imageUri, commonName, scientificName: scientificName || commonName, confidence: result.confidence, alternatives: Array.isArray(result.suggestions) ? result.suggestions.slice(1, 4).map(formatSuggestionName) : [], scanResultId: result.id, plantType: 'garden_crop', locationContext: effectiveLocationContext, approximateLocationText: approximateLocationText.trim() || undefined, notes: notes.trim() || result.notes, careSummary: 'Saved seed β€” not yet planted.', toxicityWarning: 'Safety and toxicity were not checked by this seed identification scan.', scanMode: 'seed', seed: result.mode === 'seed' ? result.seed : undefined, seedKind: result.mode === 'seed' ? seedKind : undefined, expiresAt, createdAt: now, updatedAt: now, source: 'plant_scan', }; onSavePlant(seedProfile); setSeedBankSaved(true); haptic.success(); setActionNotice('Saved to your Seed Bank.'); } catch (e) { setError('Could not save to your Seed Bank right now. Please try again.'); // eslint-disable-next-line no-console console.error('[SaveToSeedBank]', e); } finally { setSeedBankSaving(false); seedBankSavingRef.current = false; } }; /** * "Start Growing" (Phase 3 continuation, 2026-09-08). Runs a rich, * context-aware AI call for step-by-step planting instructions INCLUDING * before-planting prep / lead time, then adds the seed to the Garden as a * seed plant (growthStage 'seedling'). The planting guide is shown on the * result card (ephemeral). */ const startGrowing = async () => { if (!result || !primaryPhotoUri) return; if (growingStarted) { haptic.warning(); setActionNotice('Already added to your Garden.'); return; } if (growingRef.current) { haptic.warning(); setActionNotice('Already starting to grow.'); return; } growingRef.current = true; setGrowingLoading(true); setError(null); try { const topSuggestion = result.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || result.userProvidedName || 'Seed scan'; const scientificName = topSuggestion?.scientificName || result.scientificName; const seed = result.mode === 'seed' ? result.seed : undefined; const approx = (approximateLocationText && approximateLocationText.trim()) || result.nearbyCityOrZip; let weatherLine: string | null = null; const gps = lastGpsRef.current; if (gps) { try { const w = await getWeather(gps.latitude, gps.longitude, 'rough'); if (w) { const days = w.daily || []; const avgHigh = days.length ? days.reduce((s, d) => s + (d.tempMaxC ?? 0), 0) / days.length : null; const avgLow = days.length ? days.reduce((s, d) => s + (d.tempMinC ?? 0), 0) / days.length : null; weatherLine = [ avgHigh != null ? `avg high ${avgHigh.toFixed(0)}Β°C` : '', avgLow != null ? `avg low ${avgLow.toFixed(0)}Β°C` : '', w.humidityPct != null ? `humidity ${w.humidityPct.toFixed(0)}%` : '', ].filter(Boolean).join(', ') || null; } } catch { /* weather best-effort */ } } const guide = await getSeedPlantingGuide(commonName, scientificName, seed, { location: approx || null, weatherLine, }).catch(() => null); if (guide) setPlantingGuide(guide); // Persist the primary photo to permanent storage. let imageUri = primaryPhotoUri; try { const record = await persistPhoto(primaryPhotoUri, { photoType: 'plant_id' }); imageUri = record.activeUri; } catch { /* keep cache URI as fallback */ } const now = new Date().toISOString(); const seedProfile: SavedPlantProfile = { id: `plant_seed_${Date.now()}`, photoUri: imageUri, commonName, scientificName: scientificName || commonName, confidence: result.confidence, alternatives: Array.isArray(result.suggestions) ? result.suggestions.slice(1, 4).map(formatSuggestionName) : [], scanResultId: result.id, plantType: 'garden_crop', growthStage: 'seedling', locationContext: effectiveLocationContext, approximateLocationText: approximateLocationText.trim() || undefined, notes: notes.trim() || result.notes, careSummary: 'Seed planted β€” will need setup + care guidance.', toxicityWarning: 'Safety and toxicity were not checked by this seed identification scan.', scanMode: 'seed', seed: result.mode === 'seed' ? result.seed : undefined, seedKind: result.mode === 'seed' ? seedKind : undefined, setupCompleted: false, createdAt: now, updatedAt: now, source: 'plant_scan', }; onSavePlant(seedProfile); setGrownPlantId(seedProfile.id); setGrowingStarted(true); haptic.success(); setActionNotice('Added to your Garden β€” start growing!'); // Scroll to top + center so the user sees the planting guide (Bekky, // 2026-09-08): after Save to Seed Bank then Start Growing, the result // should be back at the top where the guide renders. requestAnimationFrame(() => { resultScrollRef.current?.scrollTo({ y: 0, animated: true }); }); } catch (e) { setError('Could not start growing right now. Please try again.'); // eslint-disable-next-line no-console console.error('[StartGrowing]', e); } finally { setGrowingLoading(false); growingRef.current = false; } }; /** * Fire-and-forget enrichment: fetches propagation + enrichment data in background. * Called after a successful scan; never blocks the UI. */ const fireEnrichment = (scanResult: PlantIdentificationResult) => { const topSuggestion = scanResult.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || scanResult.userProvidedName || ''; const scientificName = topSuggestion?.scientificName || scanResult.scientificName; if (!commonName && !scientificName) return; setEnrichmentLoading(true); // Mark this plant as having enrichment in flight (expert review 2026-09-03, // Fix 3). If the app is killed mid-enrichment, the flag persists and the // next load re-enqueues the pass. Cleared on completion. const currentId = savedProfileIdRef.current; if (currentId) { onUpdatePlant(currentId, { pendingEnrichment: true } as import('../types/plantScan').SavedPlantProfile); } // Background retry (Bekky, 2026-08-13): if the first AI call fails, retry // with backoff so a freshly-scanned plant always gets its full enrichment // data β€” even if the user saves it before the call finishes. const MAX_ATTEMPTS = 3; const BACKOFF_MS = 2000; const attempt = (n: number): Promise => enrichPlantProfile(commonName, scientificName, { organicFirst: carePreferences?.organicFirst, location: savedApproximateLocationText || null, }).then(result => { if (result) return result; if (n < MAX_ATTEMPTS) { return new Promise(resolve => setTimeout(resolve, BACKOFF_MS * n)).then(() => attempt(n + 1)); } return null; }); attempt(1).then(result => { if (result) { setEnrichmentData(result.enrichmentData); // Stable propagation is always set here (Bekky, 2026-08-20): the // weather-enriched cutting guidance lives in a SEPARATE ephemeral state // (cuttingPropagationInfo), so it can never collide with or be clobbered // by this stable species-facts version. setPropagationInfo(result.propagationInfo); // If the plant was already saved, push the enrichment data onto it so // the detail page shows the tidbits even if saved before enrichment. // Use the ref (not the closure) so we read the LATEST savedProfileId β€” // the closure captured null because fireEnrichment runs before save. const latestId = savedProfileIdRef.current; if (latestId) { const update: Record = { enrichmentData: result.enrichmentData, propagationInfo: result.propagationInfo, pendingEnrichment: false, // completed β€” clear the recovery flag // NOTE (Bekky, 2026-08-20): care schedule + guidance are NOT part of // the basics call anymore β€” they're generated separately via the // "Generate Custom Care Guidance" button (generateCareGuidance). }; onUpdatePlant(latestId, update as import('../types/plantScan').SavedPlantProfile); } } }).catch(() => { // Silently ignore β€” enrichment is best-effort. The pendingEnrichment flag // stays set so the next load re-enqueues the pass (Fix 3). }).finally(() => { setEnrichmentLoading(false); }); }; /** * "See Cutting Options" (Bekky, 2026-08-20): the scan no longer returns * propagation data (it's fetched separately via enrichment for stability). * Tapping this button fetches EPHEMERAL weather-enriched cutting guidance * (season + indoor/outdoor + weekly climate), cached per-season, then opens * the cutting modal. The guidance is shown on the card and DISCARDED β€” it is * never persisted to the plant (the plant detail keeps stable species-facts). */ const openCuttingOptions = async () => { if (!result) return; const topSuggestion = result.topSuggestion; const commonName = topSuggestion?.commonName || topSuggestion?.name || result.userProvidedName || ''; const scientificName = topSuggestion?.scientificName || result.scientificName; if (!commonName && !scientificName) return; setCuttingFetchLoading(true); setShowCuttingModal(true); // Build a SEASONAL live context (Bekky, 2026-08-20): a cutting takes weeks // to root, so today's exact temp is the wrong signal. We emphasize the // season + indoor/outdoor + weekly-average climate instead. Best-effort: // if weather/GPS is unavailable, fall back to season + indoor/outdoor only. const season = currentSeason(); // Indoor/outdoor may be UNKNOWN at scan time (the user sets it later on the // plant detail). When unknown, tell the AI to cover BOTH paths instead of // guessing (Bekky, 2026-08-20). const indoor = effectiveLocationContext === 'indoor' || plantType === 'houseplant'; const unknownPlacement = !indoor && (locationContext === 'unsure' || locationContext === 'public_park_trail' || locationContext === 'public_spaces' || locationContext === 'street_tree' || locationContext === 'nursery_store'); let placementLine = indoor ? 'indoor plant (controlled climate)' : unknownPlacement ? 'placement unknown β€” cover BOTH indoor and outdoor guidance' : 'outdoor plant'; let liveContext = `season: ${season}, ${placementLine}`; // Region-aware: add the gardener's approximate location (e.g. "Da Lat, // Vietnam") so the AI doesn't default to US-centric USDA-zone advice // (Bekky, 2026-08-26). Best-effort. const approx = (approximateLocationText && approximateLocationText.trim()) || result?.nearbyCityOrZip; if (approx) liveContext += `, gardener location: ${approx}`; const gps = lastGpsRef.current; if (gps) { try { const w = await getWeather(gps.latitude, gps.longitude, 'rough'); if (w) { setCuttingWeather(w); // Weekly-average climate (from the 7-day forecast) β€” the signal that // actually matters for a cutting that roots over weeks. const days = w.daily || []; const avgHigh = days.length ? days.reduce((s, d) => s + (d.tempMaxC ?? 0), 0) / days.length : null; const avgLow = days.length ? days.reduce((s, d) => s + (d.tempMinC ?? 0), 0) / days.length : null; const climateBits = [ `weekly avg high ${avgHigh != null ? avgHigh.toFixed(0) : '?'}Β°C`, `weekly avg low ${avgLow != null ? avgLow.toFixed(0) : '?'}Β°C`, w.humidityPct != null ? `humidity ${w.humidityPct.toFixed(0)}%` : '', ].filter(Boolean).join(', '); liveContext = `season: ${season}, ${placementLine}, ${climateBits}`; // If it's an OUTDOOR plant and the weather is extreme, tell the AI // to advise bringing the cutting indoors (Bekky, 2026-08-20). if (!indoor && !unknownPlacement && (avgHigh != null && avgHigh > 32) || (avgLow != null && avgLow < 4)) { liveContext += '. The weather is extreme right now β€” if propagating outdoors, advise bringing the cutting indoors to a sheltered spot.'; } } } catch { // Weather failed β€” fall through with season + placement only. } } // Fetch the EPHEMERAL seasonal guidance (cached per-season, never persisted). const info = await getSeasonalCuttingGuidance(commonName, scientificName, liveContext, carePreferences?.organicFirst).catch(() => null); if (info) { setCuttingPropagationInfo(info); } setCuttingFetchLoading(false); }; /** * Save the scanned plant as a cutting (plantType='cutting') directly to the garden. */ const addAsCuttingToGarden = async () => { if (!primaryPhotoUri || !result) return; if (savedProfileId) { onViewPlant(savedProfileId); return; } const topSuggestion = result.topSuggestion; if (!topSuggestion) return; const commonName = topSuggestion.commonName || topSuggestion.name || result.userProvidedName || 'Plant scan'; const scientificName = topSuggestion.scientificName || result.scientificName || commonName; const now = new Date().toISOString(); const cuttingProfile: SavedPlantProfile = { id: `plant_scan_${Date.now()}`, photoUri: primaryPhotoUri, commonName, scientificName, confidence: result.confidence, alternatives: Array.isArray(result.suggestions) ? result.suggestions.slice(1, 4).map(formatSuggestionName) : [], plantType: 'cutting', locationContext: effectiveLocationContext, notes: notes.trim() || result.notes, careSummary: 'No care plan generated from this scan yet.', toxicityWarning: 'Safety and toxicity were not checked by this plant identification scan.', createdAt: now, updatedAt: now, source: 'plant_scan', enrichmentData: enrichmentData || undefined, propagationInfo: propagationInfo || undefined, }; onSavePlant(cuttingProfile); setSavedProfileId(cuttingProfile.id); setSavedIsCutting(true); setActionNotice('Cutting added to Garden!'); // Stable enrichment (bio + propagation) runs AFTER add (Bekky, 2026-08-20). if (result) fireEnrichment(result); }; if (result) { const topSuggestion = result.topSuggestion; const alternatives = Array.isArray(result.suggestions) ? result.suggestions.slice(1, 4) : []; const topName = topSuggestion ? formatSuggestionName(topSuggestion) : result.userProvidedName || 'this plant'; const scientificName = topSuggestion?.scientificName || result.scientificName; const commonNames = Array.isArray(result.commonNames) && result.commonNames.length ? result.commonNames.slice(0, 3).join(', ') : undefined; // Fungus ownership gate (Bekky, 2026-08-25): you may add/cultivate a fungus // only when it's in YOUR space (home / garden_yard). A trail/park find is // info-only β€” we don't encourage claiming or cultivating wild/public fungi. // REVERSED (Bekky, 2026-09-08): fungi can be added to the Garden regardless // of where they were found β€” it's not our job to police what people do with // a find (e.g. relocating something from a friend's house). Always true. const isOwnedFungus = true; return ( <> setResult(null)} activeOpacity={0.75} style={styles.iconBackButton}> {'<'} Plant ID {primaryPhotoUri ? : null} Top suggestion Pixie thinks this may be... {topName} {scientificName ? {scientificName} : null} {result.confidence}% confidence {commonNames ? ( <> Common names {commonNames} ) : null} {result.mode === 'fungus' && result.kingdom ? ( <> What is it {fungusKingdomLabel(result.kingdom)} ) : null} {result.mode === 'fungus' && result.edibility ? ( <> Edibility (unverified) {fungusEdibilityLabel(result.edibility)} ) : null} {result.mode === 'fungus' ? ( ⚠️ Never eat a mushroom based only on this identification. Mushroom foraging is an expert skill, and a photo ID is not enough to confirm edibility β€” many lookalikes are poisonous. Never consume any mushroom unless a local expert has confirmed it. This scan is for identification and interest only. ⚠️ Check local laws and regulations before taking any plant, fungus, moss, or lichen from the wild β€” some species are protected, and collection may be restricted or require a permit. ) : null} {/* Toxicity disclaimer for ANY scan flagged toxic/caution (Bekky, 2026-09-08): the AI always fetches a toxicity label when possible. If this plant is toxic/caution, show a due-diligence disclaimer + what's known about it. We're not the end-all-be-all. */} {result.mode !== 'fungus' && (result.petSafety === 'toxic' || result.petSafety === 'caution') ? ( {result.petSafety === 'toxic' ? '⚠️ Toxic to pets' : '⚠️ Caution β€” may irritate pets'} This plant may be {result.petSafety === 'toxic' ? 'toxic' : 'mildly irritating'} to pets or people. Pixie's identification isn't the final word β€” do your own due diligence and confirm with a reliable source or local expert before handling, consuming, or placing it near pets or children. ) : null} {/* Local-laws disclaimer for ANY find outside your own home/garden (Bekky, 2026-09-08): a field walk, bike ride, or scan outside your space should carry a small note to check local laws before taking something from the wild. Small + present, not verbose. NOT shown for seed packets or market/store-bought fruit (Bekky, 2026-09-08) β€” those aren't wild harvests. */} {result.mode !== 'fungus' && effectiveLocationContext !== 'home' && effectiveLocationContext !== 'garden_yard' && !(result.mode === 'seed' && seedKind !== 'wild') ? ( ⚠️ Check local laws before taking anything from the wild β€” some species are protected. ) : null} {result.mode === 'seed' && result.seed ? ( ) : null} {/* Tiered seed guidance (Phase 3 continuation, 2026-09-08): after the user commits to Save to Seed Bank or Start Growing, show the rich context-aware prep/planting guide on the result card. */} {result.mode === 'seed' && seedBankPrep ? ( 🌱 Seed Bank β€” storage & prep {seedBankPrep.prepSteps && seedBankPrep.prepSteps.length ? ( Prep steps {seedBankPrep.prepSteps.map((step, i) => ( - {step} ))} ) : null} {seedBankPrep.leadTimePrep ? Lead time : null} {seedBankPrep.leadTimePrep ? {seedBankPrep.leadTimePrep} : null} {seedBankPrep.storageMethod ? How to store : null} {seedBankPrep.storageMethod ? {seedBankPrep.storageMethod} : null} {seedBankPrep.storageDuration ? Stays viable : null} {seedBankPrep.storageDuration ? {seedBankPrep.storageDuration} : null} {seedBankPrep.mustPlantFresh ? ( ⚠️ This seed must be planted fresh β€” do not dry or refrigerate it. ) : null} {seedBankPrep.note ? {seedBankPrep.note} : null} ) : null} {result.mode === 'seed' && plantingGuide ? ( 🌿 Start Growing β€” planting guide {plantingGuide.prepSteps && plantingGuide.prepSteps.length ? ( Before planting (prep) {plantingGuide.prepSteps.map((step, i) => ( - {step} ))} ) : null} {plantingGuide.leadTime ? Lead time : null} {plantingGuide.leadTime ? {plantingGuide.leadTime} : null} {plantingGuide.plantingSteps && plantingGuide.plantingSteps.length ? ( How to plant {plantingGuide.plantingSteps.map((step, i) => ( - {step} ))} ) : null} {plantingGuide.whenToPlant ? When to plant : null} {plantingGuide.whenToPlant ? {plantingGuide.whenToPlant} : null} {plantingGuide.germination ? Germination : null} {plantingGuide.germination ? {plantingGuide.germination} : null} {plantingGuide.expectedOutcome ? What to expect : null} {plantingGuide.expectedOutcome ? {plantingGuide.expectedOutcome} : null} {plantingGuide.aftercare ? Aftercare : null} {plantingGuide.aftercare ? {plantingGuide.aftercare} : null} {plantingGuide.note ? {plantingGuide.note} : null} ) : null} {result.referenceUrl ? ( Read more on Wikipedia ) : null} If this doesn't look right, add more details or clearer photos and scan again. Possible match details {result.description ? ( <> Description {result.description} ) : null} {result.sourceUrl ? ( <> Source {result.sourceUrl} ) : null} Alternate suggestions {alternatives.length ? alternatives.map(alternative => ( - {formatSuggestionName(alternative)} ({alternative.confidence}%) )) : No close alternate suggestions were returned.} {actionNotice ? {actionNotice} : null} {error ? {error} : null} {plantIdentificationTrustMessage} {result.mode === 'seed' ? ( <> {/* Seed-specific pathways (Phase 3 continuation, 2026-09-08): Save to Seed Bank / Start Growing / Wishlist / Field Diary. Seed Bank + Start Growing run rich context-aware AI calls; Wishlist + Field Diary just save (no new call). */} {seedBankSaved ? 'βœ“ Saved to Seed Bank' : seedBankSaving ? 'Saving…' : '🌱 Save to Seed Bank'} {growingStarted ? 'βœ“ Added to Garden' : growingLoading ? 'Preparing…' : '🌿 Start Growing'} {result.addedToWishlist ? 'Wishlist Saved' : 'Add to Wishlist'} {fieldDiarySaved ? 'βœ“ Saved to Field Diary' : 'πŸ“ Save to Field Diary'} Scan Again ) : !isOwnedFungus ? ( <> Save to Wishlist {fieldDiarySaved ? 'βœ“ Saved to Field Diary' : 'πŸ“ Save to Field Diary'} ) : ( <> {savedProfileId && savedIsCutting ? ( // A cutting was already added via the modal. Swap the two buttons // so it's not confusing (Bekky, 2026-08-20): // primary β†’ "Add Plant to Garden" (save the whole plant too) // secondary β†’ "View in Garden" (view the cutting just made) Add Plant to Garden ) : ( {savedProfileId ? 'View in Garden' : 'Add to Garden'} )} {savedProfileId && savedIsCutting ? ( onViewPlant(savedProfileId)}> View in Garden ) : propagationInfo && propagationInfo.canPropagate !== false ? ( βœ‚οΈ Add Cutting to Garden ) : ( {cuttingFetchLoading ? 'Checking…' : 'See Cutting Options'} )} {result.addedToWishlist ? 'Wishlist Saved' : 'Add to Wishlist'} {/* Field Diary is available for EVERY scan, not just info-only finds β€” a walk find is worth logging regardless of whether it's also going to the garden (Bekky, 2026-08-26: it was missing on normal plants). */} {fieldDiarySaved ? 'βœ“ Saved to Field Diary' : 'πŸ“ Save to Field Diary'} Scan Again )} {/* Pet-safety notice β€” MUST be in the result branch too (Bekky, 2026-08-19). addToGarden early-returns here on pet-caution, but the modal was only in the main branch, so the tap did nothing on the result screen. */} {petWarning ? ( setPetWarning(null)} buttons={[ { text: 'Cancel', style: 'cancel', onPress: () => setPetWarning(null) }, { text: 'Add anyway', style: 'default', onPress: () => { setPetWarning(null); doAddToGarden(); } }, ]} /> ) : null} {/* Cutting options modal β€” fetched on demand via "See Cutting Options" (Bekky, 2026-08-20). Weather line shows live conditions when available. */} { setShowCuttingModal(false); addAsCuttingToGarden(); }} onAddToGarden={() => { // Plant can't be a cutting β€” offer to add the identified plant itself. setShowCuttingModal(false); addToGarden(); }} onAddToWishlist={() => { setShowCuttingModal(false); addToWishlist(); }} onClose={() => { setShowCuttingModal(false); setCuttingFetchLoading(false); // Discard the ephemeral guidance (Bekky, 2026-08-20) β€” it's just for // the card, never persisted. setCuttingPropagationInfo(null); }} /> ); } const cameraGranted = cameraPermission?.granted ?? false; return ( {cameraGranted ? ( ) : ( )} Back {isFungusScan ? 'Fungus Mode' : isSeedScan ? 'Seed Mode' : 'Plant Mode'} {isSeedScan ? ( What are you scanning? Pick the closest match so Pixie knows what details to look for. If you're not sure, pick Unknown wild seed. { setSeedKind(next); // Phase 1B (2026-09-07): changing the main path resets the forced // refinement so the user picks the right one for the new path. setSeedRefinement(null); // Phase 1C (2026-09-07): reset the wild-attached plant-type row too. setSeedPlantType(null); }} /> {seedKind !== 'packet' ? ( <> Refine your find Pick one so Pixie knows more about your find. This is required before scanning. { setSeedRefinement(next as SeedRefinement); // Phase 1C (2026-09-07): changing the refinement resets the // wild-attached plant-type row so the user picks the right one. setSeedPlantType(null); }} /> {seedKind === 'wild' && seedRefinement === 'attached' ? ( <> What kind of plant was it attached to? Pick one so Pixie knows what plant to look for. setSeedPlantType(next as SeedPlantType)} /> ) : null} ) : null} ) : !isFungusScan ? ( Plant type ) : ( What are you scanning? Choose the closest match so Pixie knows what details to look for. If you're not sure, pick Unsure. )} {isFungusScan ? 'Fungus photos' : isSeedScan ? 'Seed photos' : 'Plant photos'} {isSeedScan && !seedChipsComplete ? ( Finish the chips above to see the right photo slots for your find. ) : ( <> {photoRequirementCopy} {visiblePhotoSlots.map(slot => ( takePhoto(slot.id)} onChoose={() => choosePhoto(slot.id)} onPhotoPress={(uri) => setPreviewUri(uri)} /> ))} )} Helpful details {isSeedScan && seedKind === 'wild' ? ( <> Plant context Surrounding plants ) : null} Location context {isSeedScan && (seedKind === 'pod' || seedKind === 'wild' || (seedKind === 'fruit' && seedRefinement === 'wild')) ? ( Highly encouraged for found/unknown finds β€” helps Pixie identify it. ) : null} {showLocationContext ? ( setLocationMenuOpen(open => !open)}> {selectedLocationLabel} {locationMenuOpen ? '^' : 'v'} {locationMenuOpen ? ( {locationContexts.map(choice => ( { setLocationContext(choice.value); setLocationMenuOpen(false); }} > {choice.label} ))} ) : null} ) : ( Indoor )} {validation ? {validation} : null} {error ? {error} : null} Location accuracy setLocationSourceMenuOpen(open => !open)}> {locationSourceOptions.find(o => o.value === locationSource)?.label || 'Select...'} {locationSourceMenuOpen ? '^' : 'v'} {locationSourceMenuOpen ? ( {locationSourceOptions.map(option => ( { setLocationSource(option.value); setLocationSourceMenuOpen(false); }} > {option.label} ))} ) : null} {locationSource === 'manual' ? ( ) : ( Pixie will use your device location. You can change this anytime in Settings. )} {/* Phase 2b (2026-09-07): separate "Found location" section β€” for when you're not where you found it. Embedded map with search (the search also accepts Google Maps links and plus codes). Phase 2h (2026-09-07): simplified to just the map. Phase 2i (2026-09-07): hidden for seed packet + store-bought fruit (no map logic needed). */} {showFoundLocationMap ? ( <> Found location If you're not where you were when you found it, enter the location you found it in for best results. {mapSearching ? '…' : 'Search'} {mapSearchError ? {mapSearchError} : null} {mapSearchResults && mapSearchResults.length > 0 ? ( Multiple matches β€” pick the right one {mapSearchResults.map((r, i) => ( { setMapSearchResults(null); moveMapTo(r.latitude, r.longitude); }} > {r.label} ))} ) : null} { const { latitude, longitude } = e.nativeEvent.coordinate; setMapPin({ latitude, longitude }); }} showsUserLocation showsCompass toolbarEnabled={false} > {mapPin ? ( ) : null} {mapPin ? `Pin dropped at ${mapPin.latitude.toFixed(4)}, ${mapPin.longitude.toFixed(4)}. Tap the map to move it.` : 'Search for a place, or tap the map to drop a pin where you found it.'} ) : null} {loading ? ( {(isSeedScan ? seedLoadingCopy : loadingCopy)[loadingCopyIndex]} ) : Identify Plant} {/* Full-screen photo preview */} {previewUri ? ( setPreviewUri(null)} > βœ• ) : null} {petWarning ? ( setPetWarning(null)} buttons={[ { text: 'Cancel', style: 'cancel', onPress: () => setPetWarning(null) }, { text: 'Add anyway', style: 'default', onPress: () => { setPetWarning(null); doAddToGarden(); } }, ]} /> ) : null} ); } function formatSuggestionName(suggestion: PlantIdentificationSuggestion) { const name = suggestion.commonName || suggestion.name || suggestion.scientificName || 'Possible plant'; return cleanPlantDisplayName(name); } /** Rich seed-saving + germination profile card (Bekky, 2026-09-07, seed-intake * feature). Rendered for seed scans (result.mode === 'seed' && result.seed). * Uses the app's visual language β€” white cards, green accents, Alegreya-style * headers (via the existing result card styles). */ /** Heuristic: is a germination-rate string "low"? (Bekky, 2026-09-08) β€” used * to disclose low-germination seeds before planting. Parses the first number * in the string (e.g. "60-80%", "30%", "low ~20%"). */ function isLowGerminationRate(rate: string): boolean { const m = rate.match(/(\d+)/); if (!m) return false; const n = parseInt(m[1], 10); return n < 50; } /** Compute an expiry date for a saved seed (Bekky, 2026-09-08). Recalcitrant * seeds (must plant fresh) can only be stored a short time β€” parse the * storage duration (e.g. "1-2 weeks", "a few days", "3 months") into a rough * expiry date. Orthodox seeds (can be dried & stored) get no expiry. Returns * an ISO string, or undefined if no expiry applies. */ function computeSeedExpiry(seed: SeedIdentificationResult | undefined): string | undefined { if (!seed) return undefined; // Only recalcitrant seeds (must plant fresh) get an expiry. if (seed.seedType !== 'recalcitrant') return undefined; const dur = (seed.storageDuration || '').toLowerCase(); // Parse the first number + unit. const m = dur.match(/(\d+)\s*(day|week|month|year|hr|hour)s?/); if (!m) return undefined; const n = parseInt(m[1], 10); const unit = m[2]; let days = 0; if (unit.startsWith('day')) days = n; else if (unit.startsWith('week')) days = n * 7; else if (unit.startsWith('month')) days = n * 30; else if (unit.startsWith('year')) days = n * 365; else if (unit.startsWith('hr')) days = Math.max(1, Math.round(n / 24)); if (days <= 0) return undefined; // Cap at a reasonable window (recalcitrant seeds don't last long). const capped = Math.min(days, 90); return new Date(Date.now() + capped * 24 * 60 * 60 * 1000).toISOString(); } function SeedResultCard({ seed }: { seed: SeedIdentificationResult }) { const seedTypeLabel = seed.seedType === 'orthodox' ? 'Orthodox β€” can be dried and stored for later planting.' : seed.seedType === 'recalcitrant' ? 'Recalcitrant β€” must be planted fresh. Dies if dried or refrigerated.' : 'Seed type unknown.'; const comesTrueLabel = seed.comesTrueFromSeed === true ? 'Yes β€” comes true from seed.' : seed.comesTrueFromSeed === false ? 'No β€” does NOT come true from seed.' : 'Unknown whether it comes true from seed.'; const lightLabel = seed.lightRequirement === 'light' ? 'Light (needs light to germinate)' : seed.lightRequirement === 'dark' ? 'Dark (needs darkness to germinate)' : 'Either light or dark'; return ( 🌱 Seed-saving & germination guide Seed type {seedTypeLabel} Comes true from seed? {comesTrueLabel} {seed.comesTrueNote ? {seed.comesTrueNote} : null} {seed.canSaveSeed !== undefined ? ( <> Can you save this seed? {seed.canSaveSeed ? 'Yes' : 'No'} ) : null} {seed.savingMethod ? ( <> How to save {seed.savingMethod} ) : null} {seed.storageMethod ? ( <> How to store {seed.storageMethod} ) : null} {seed.storageDuration ? ( <> How long it stays viable {seed.storageDuration} ) : null} {seed.stratification && seed.stratification.needed ? ( <> Stratification (cold treatment) {[seed.stratification.method, seed.stratification.duration].filter(Boolean).join(' β€” ')} ) : null} {seed.scarification && seed.scarification.needed ? ( <> Scarification (hard coat) {seed.scarification.method} ) : null} {seed.plantingDepth ? ( <> Planting depth {seed.plantingDepth} ) : null} Light requirement {lightLabel} {seed.temperatureRange ? ( <> Temperature {seed.temperatureRange} ) : null} {seed.moisture ? ( <> Moisture {seed.moisture} ) : null} {seed.daysToGerminate ? ( <> Days to germinate {seed.daysToGerminate} ) : null} {seed.germinationRate ? ( <> Germination rate {seed.germinationRate} {isLowGerminationRate(seed.germinationRate) ? '⚠️ This seed has a low germination rate β€” not every seed you plant is likely to sprout. Consider planting a few extra to be safe.' : 'Not every seed is guaranteed to sprout, but this one has a decent germination rate.'} ) : null} {seed.germinationSteps && seed.germinationSteps.length ? ( <> Germination steps {seed.germinationSteps.map((step, i) => ( - {step} ))} ) : null} {seed.propagationMethods && seed.propagationMethods.length ? ( <> Seed propagation methods {seed.propagationMethods.map((m, i) => ( {m.method} {m.difficulty} {m.timeframe ? {m.timeframe} : null} {m.instructions && m.instructions.length ? m.instructions.map((inst, j) => ( - {inst} )) : null} ))} ) : null} {seed.timeToGrow ? ( <> Time to grow {seed.timeToGrow} ) : null} {seed.timeToHarvest ? ( <> Time to harvest {seed.timeToHarvest} ) : null} {seed.timeToFruit ? ( <> Time to fruit {seed.timeToFruit} ) : null} {seed.expectedOutcome ? ( <> What it grows into {seed.expectedOutcome} ) : null} {seed.spaceNeeded ? ( <> Space needed {seed.spaceNeeded} ) : null} ); } function getIdentificationErrorMessage(reason: unknown) { if (reason instanceof PlantIdentificationError) { if (reason.code === 'NO_IMAGE') return 'Add a plant photo before identifying.'; if (reason.code === 'MISSING_API_KEY') return 'Plant ID is not configured yet.'; if (reason.code === 'AUTH_FAILURE') { if (typeof __DEV__ !== 'undefined' && __DEV__ && reason.devMessage) { return `Plant ID could not sign in. ${reason.devMessage}`; } return 'Plant ID could not sign in. Please check the API key.'; } if (reason.code === 'NETWORK_FAILURE') return 'Pixie could not reach Plant ID right now. Please try again in a bit.'; if (reason.code === 'NO_CONFIDENT_RESULT') return 'Pixie could not find a confident plant match from this photo. Try a clearer leaf, flower, or whole-plant photo.'; if (reason.code === 'PROVIDER_UNAVAILABLE') { if (typeof __DEV__ !== 'undefined' && __DEV__ && reason.devMessage) { return `Plant ID is resting right now. ${reason.devMessage}`; } return 'Plant ID is resting right now. Please try again in a bit.'; } } return 'Plant identification is unavailable right now. Please try again.'; } function ChoiceWrap({ choices, value, onChange }: { choices: Choice[]; value: T; onChange: (value: T) => void }) { return ( {choices.map(choice => ( onChange(choice.value)} > {choice.label} ))} ); } function PhotoSlotCard({ title, requirement, helper, emptyText, photo, onTake, onChoose, onPhotoPress, }: { title: string; requirement: string; helper: string; emptyText: string; photo: ScanPhotoState | null; onTake: () => void; onChoose: () => void; onPhotoPress?: (uri: string) => void; }) { return ( {title} {helper} {requirement} {photo ? ( onPhotoPress?.(photo.uri)}> ) : ( {emptyText} )} Take photo Choose photo ); } const green = '#2E6B3F'; const dark = '#2E6B3F'; const cream = 'rgba(255,253,247,0.82)'; const line = '#BFD9B4'; const styles = StyleSheet.create({ cameraContainer: { flex: 1, backgroundColor: '#1a1a1a' }, cameraPreview: { ...StyleSheet.absoluteFillObject }, cameraFallback: { ...StyleSheet.absoluteFillObject, backgroundColor: dark }, cameraVeil: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.35)' }, safe: { flex: 1, backgroundColor: 'transparent' }, content: { paddingHorizontal: 18, paddingTop: 4, paddingBottom: 270 }, resultContent: { paddingHorizontal: 18, paddingTop: 0 }, headerRow: { height: 50, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, resultHeaderRow: { height: 45, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, backButton: { minWidth: 62, minHeight: 38, justifyContent: 'center' }, backText: { color: green, fontSize: 15, fontWeight: '900' }, iconBackButton: { width: 62, minHeight: 42, justifyContent: 'center' }, iconBackText: { color: '#FFFDF7', fontSize: 34, lineHeight: 38, fontWeight: '900', textShadowColor: 'rgba(0,0,0,0.45)', textShadowRadius: 5 }, title: { color: '#FFFDF7', fontSize: 23, fontWeight: '900', textShadowColor: 'rgba(0,0,0,0.45)', textShadowRadius: 5 }, resultHeaderTitle: { color: '#FFFDF7', fontSize: 25, fontWeight: '900', textShadowColor: 'rgba(0,0,0,0.45)', textShadowRadius: 5 }, headerSpacer: { width: 62 }, card: { backgroundColor: cream, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', padding: 12, marginBottom: 10 }, compactCard: { paddingBottom: 11 }, sectionTitle: { color: dark, fontSize: 18, fontWeight: '900', marginBottom: 8 }, photoHint: { color: '#706A58', fontSize: 12, lineHeight: 17, fontWeight: '800', marginTop: -3, marginBottom: 9 }, photoSlotStack: { gap: 10 }, photoSlotCard: { borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', padding: 10 }, photoSlotHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 8 }, photoSlotCopy: { flex: 1, minWidth: 0 }, photoSlotTitle: { color: dark, fontSize: 14, lineHeight: 18, fontWeight: '900' }, photoSlotHelper: { color: '#706A58', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 2 }, photoRequirement: { flexShrink: 0, color: '#706A58', fontSize: 10, lineHeight: 13, fontWeight: '900', borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFFDF7', paddingHorizontal: 8, paddingVertical: 4, overflow: 'hidden' }, photoRequirementRequired: { color: dark, backgroundColor: '#E8F5D3' }, photoSlotBody: { flexDirection: 'row', alignItems: 'stretch', gap: 10 }, photoPreview: { width: 118, height: 118, borderRadius: 15, backgroundColor: '#E8F5D3', flexShrink: 0 }, photoEmpty: { width: 118, height: 118, borderRadius: 15, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', flexShrink: 0, paddingHorizontal: 8 }, photoEmptyText: { color: '#706A58', fontWeight: '800', fontSize: 12, lineHeight: 16, textAlign: 'center' }, photoActions: { flex: 1, minWidth: 0, height: 118, justifyContent: 'space-between', gap: 14 }, secondaryButton: { flex: 1, minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: line, alignItems: 'center', justifyContent: 'center', backgroundColor: '#FFF9EE', paddingHorizontal: 8 }, secondaryButtonText: { color: dark, fontWeight: '900', fontSize: 13, textAlign: 'center' }, validationText: { color: '#B74A35', fontSize: 13, lineHeight: 18, fontWeight: '900', marginTop: 9 }, label: { color: dark, fontSize: 13, fontWeight: '900', marginTop: 8, marginBottom: 6 }, input: { minHeight: 42, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, color: '#2F302A', fontSize: 13, fontWeight: '700', marginBottom: 7 }, notesInput: { minHeight: 58, paddingTop: 10, textAlignVertical: 'top' }, choiceWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 7, marginBottom: 1 }, choiceChip: { borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 11, paddingVertical: 7 }, choiceChipActive: { backgroundColor: green, borderColor: green }, choiceText: { color: dark, fontSize: 12, fontWeight: '900' }, choiceTextActive: { color: '#fff' }, dropdownWrap: { marginBottom: 2 }, dropdownButton: { minHeight: 46, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 13, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, dropdownText: { color: dark, fontSize: 13, fontWeight: '900' }, dropdownChevron: { color: green, fontSize: 17, fontWeight: '900' }, dropdownMenu: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFFDF7', marginTop: 6, overflow: 'hidden' }, dropdownOption: { minHeight: 42, justifyContent: 'center', paddingHorizontal: 13, borderBottomWidth: 1, borderBottomColor: 'rgba(191,217,180,0.6)' }, dropdownOptionActive: { backgroundColor: '#E8F5D3' }, dropdownOptionText: { color: dark, fontSize: 13, fontWeight: '800' }, dropdownOptionTextActive: { color: dark, fontWeight: '900' }, lockedContextChip: { alignSelf: 'flex-start', minHeight: 38, borderRadius: 19, backgroundColor: '#E8F5D3', borderWidth: 1, borderColor: line, paddingHorizontal: 16, justifyContent: 'center', marginBottom: 2 }, lockedContextText: { color: dark, fontSize: 13, fontWeight: '900' }, hintText: { color: '#706A58', fontSize: 12, fontWeight: '700', marginTop: 4, marginBottom: 2, lineHeight: 16 }, mapWrap: { marginTop: 6, marginBottom: 4 }, mapCanvas: { height: 220, borderRadius: 16, borderWidth: 1, borderColor: line, overflow: 'hidden' }, mapSearchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }, mapSearchInput: { flex: 1, marginBottom: 0 }, mapSearchButton: { minHeight: 46, borderRadius: 16, backgroundColor: green, paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' }, mapSearchButtonText: { color: '#fff', fontSize: 13, fontWeight: '900' }, mapResultsWrap: { marginBottom: 8 }, mapResultRow: { minHeight: 44, borderRadius: 12, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, justifyContent: 'center', marginBottom: 6 }, mapResultText: { color: dark, fontSize: 13, fontWeight: '800' }, errorCard: { borderRadius: 18, padding: 12, backgroundColor: '#FFE3DB', borderWidth: 1, borderColor: '#F4C6B8', marginBottom: 12 }, inlineErrorCard: { borderRadius: 14, padding: 10, backgroundColor: '#FFE3DB', borderWidth: 1, borderColor: '#F4C6B8', marginTop: 8, marginBottom: 8 }, errorText: { color: '#8E3D2F', fontWeight: '900', lineHeight: 18 }, primaryButton: { minHeight: 52, borderRadius: 26, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginTop: 10, marginBottom: 10 }, primaryButtonDisabled: { opacity: 0.72 }, primaryButtonText: { color: '#fff', fontSize: 17, fontWeight: '900' }, resultCard: { backgroundColor: cream, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', padding: 12, marginBottom: 18 }, resultPhoto: { width: '100%', height: 126, borderRadius: 16, backgroundColor: '#E8F5D3', marginBottom: 9 }, resultEyebrow: { color: green, fontSize: 11, fontWeight: '900', textTransform: 'uppercase', marginBottom: 2 }, resultLead: { color: '#706A58', fontSize: 13, lineHeight: 18, fontWeight: '800', marginBottom: 2 }, resultName: { color: dark, fontSize: 23, fontWeight: '900' }, scientificName: { color: '#706A58', fontSize: 13, fontStyle: 'italic', fontWeight: '700', marginTop: 1 }, confidence: { color: dark, fontSize: 13, fontWeight: '900', marginTop: 5 }, body: { color: '#2F302A', fontSize: 14, lineHeight: 20, fontWeight: '700' }, bullet: { color: '#2F302A', fontSize: 14, lineHeight: 20, fontWeight: '700' }, plantInfoBox: { flexGrow: 1, borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 10, paddingTop: 8, paddingBottom: 9, marginTop: 9 }, plantInfoHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10 }, plantInfoTitle: { color: dark, fontSize: 14, fontWeight: '900' }, readMoreText: { color: green, fontSize: 13, fontWeight: '900' }, referenceButton: { alignSelf: 'flex-start', minHeight: 34, borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, marginTop: 8 }, referenceButtonText: { color: green, fontSize: 13, fontWeight: '900' }, identificationHint: { color: '#706A58', fontSize: 12, lineHeight: 16, fontWeight: '800', marginTop: 7 }, identificationDisclaimer: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 9 }, fungusDisclaimerBox: { marginTop: 12, padding: 12, borderRadius: 10, backgroundColor: '#FDF0E7', borderWidth: 1, borderColor: '#E7C9A8' }, fungusDisclaimerTitle: { color: '#9A5B2A', fontSize: 13, fontWeight: '900', marginBottom: 4, lineHeight: 18 }, fungusDisclaimerText: { color: '#7A5B3A', fontSize: 12, lineHeight: 17, fontWeight: '600' }, localLawsBox: { marginTop: 12, padding: 10, borderRadius: 10, backgroundColor: '#FDF0E7', borderWidth: 1, borderColor: '#E7C9A8' }, localLawsText: { color: '#7A5B3A', fontSize: 12, lineHeight: 17, fontWeight: '600' }, toxicityBox: { marginTop: 12, padding: 12, borderRadius: 10, backgroundColor: '#FDF0E7', borderWidth: 1, borderColor: '#E7C9A8' }, toxicityTitle: { color: '#9A5B2A', fontSize: 13, fontWeight: '900', marginBottom: 4, lineHeight: 18 }, toxicityText: { color: '#7A5B3A', fontSize: 12, lineHeight: 17, fontWeight: '600' }, seedResultBox: { marginTop: 12, borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12 }, seedResultTitle: { color: dark, fontSize: 16, fontWeight: '900', marginBottom: 8 }, seedTierBox: { marginTop: 12, borderRadius: 17, borderWidth: 1.5, borderColor: green, backgroundColor: '#F0F7EA', paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12 }, seedTierTitle: { color: dark, fontSize: 15, fontWeight: '900', marginBottom: 6 }, seedTierSection: { marginBottom: 4 }, labelCompact: { color: dark, fontSize: 12, fontWeight: '900', marginTop: 6, marginBottom: 2 }, bodyCompact: { color: '#2F302A', fontSize: 13, lineHeight: 17, fontWeight: '700' }, bulletCompact: { color: '#2F302A', fontSize: 13, lineHeight: 17, fontWeight: '700' }, actionNotice: { color: dark, fontSize: 13, lineHeight: 18, fontWeight: '900', textAlign: 'center', marginTop: 9 }, resultActionFooter: { position: 'absolute', left: 18, right: 18, zIndex: 28, elevation: 28, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: 'rgba(255,253,247,0.98)', padding: 9 }, saveButton: { minHeight: 43, borderRadius: 22, backgroundColor: green, alignItems: 'center', justifyContent: 'center' }, saveButtonText: { color: '#fff', fontSize: 15, fontWeight: '900' }, resultSecondaryButton: { minHeight: 43, borderRadius: 22, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', marginTop: 7 }, resultSecondaryButtonText: { color: dark, fontSize: 14, fontWeight: '900' }, scanAnotherButton: { minHeight: 42, borderRadius: 21, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center', marginTop: 7 }, scanAnotherButtonText: { color: dark, fontSize: 14, fontWeight: '900' }, cuttingButton: { minHeight: 43, borderRadius: 22, borderWidth: 1, borderColor: '#B8D4AE', backgroundColor: '#DCEED3', alignItems: 'center', justifyContent: 'center', marginTop: 7 }, cuttingButtonText: { color: dark, fontSize: 14, fontWeight: '900' }, propagationBox: { borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 14, paddingTop: 12, paddingBottom: 12, marginTop: 9 }, propagationBoxTitle: { color: dark, fontSize: 16, fontWeight: '900', marginBottom: 8 }, propagationLoadingText: { color: '#706A58', fontSize: 13, fontWeight: '800', fontStyle: 'italic' }, propagationMethodCard: { borderRadius: 14, borderWidth: 1, borderColor: line, backgroundColor: '#FFFFFF', padding: 12, marginBottom: 8 }, propagationMethodHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, propagationMethodName: { color: dark, fontSize: 16, fontWeight: '900', flex: 1 }, propagationBadge: { borderRadius: 999, paddingHorizontal: 10, paddingVertical: 3, overflow: 'hidden' }, propagationBadgeText: { fontSize: 12, fontWeight: '900', textTransform: 'capitalize' }, propagationSuccessRate: { color: '#2E6B3F', fontSize: 13, fontWeight: '700', marginTop: 4 }, propagationTimeframe: { color: '#5D5A4E', fontSize: 13, lineHeight: 19, fontWeight: '600', marginTop: 2 }, propagationMoreText: { color: '#706A58', fontSize: 13, fontWeight: '800', marginTop: 4 }, propagationNote: { color: '#5D5A4E', fontSize: 13, lineHeight: 19, fontWeight: '600', marginTop: 6 }, propagationNoInfo: { color: '#706A58', fontSize: 13, fontWeight: '800', fontStyle: 'italic' }, loadingButtonContent: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 9 }, photoPreviewOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.92)', zIndex: 100, elevation: 100, justifyContent: 'center', alignItems: 'center' }, photoPreviewFull: { width: '100%', height: '100%' }, photoPreviewClose: { position: 'absolute', top: 50, right: 18, zIndex: 101, elevation: 101 }, photoPreviewCloseText: { color: '#fff', fontSize: 24, fontWeight: '900' }, });