/** * Profile / Garden screen for PixieSprout. * The main garden management interface. */ import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Animated, BackHandler, Easing, Image, ImageSourcePropType, KeyboardAvoidingView, Linking, Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import DateTimePicker, { DateTimePickerAndroid, type DateTimePickerEvent } from '@react-native-community/datetimepicker'; // EdgeModal — RN's Modal types don't include navigationBarTranslucent/statusBarTranslucent // (added at runtime by react-native-edge-to-edge). Cast once, use everywhere. const EdgeModal = Modal as any; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system'; import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; import { brand, green, dark, parchment, line, brown, haptic, pressWithHaptic } from '../constants/theme'; import { PhotoPickerModal } from '../components/PhotoPickerModal'; import { CuttingGuidelinesModal } from '../components/CuttingGuidelinesModal'; import { GlossaryText } from '../components/GlossaryText'; import { PhotoGuidanceModal, type PhotoGuidanceType } from '../components/PhotoGuidanceModal'; import { PixieAlert } from '../components/PixieAlert'; import { SharedContainerModal } from '../components/SharedContainerModal'; import { ExcursionMap } from '../components/ExcursionMap'; import { startExcursion, stopExcursion, getActiveExcursion, formatExcursionDuration, formatExcursionDistance, } from '../services/excursion/excursionService'; import { CadenceWheelModal } from '../components/CadenceWheelModal'; import { CollapsibleSection } from '../components/CollapsibleSection'; import { ResolveInvestigationModal } from '../components/ResolveInvestigationModal'; import { CheckInModal } from '../components/CheckInModal'; import { PhotoGalleryModal, type GalleryPhoto } from '../components/PhotoGalleryModal'; import { SeedBankModal } from '../components/SeedBankModal'; import { PlantSeedModal } from '../components/PlantSeedModal'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ROOTING_METHOD_ICONS, cuttingMethodLabel, filterCuttingMethods } from '../types/propagation'; import { isCuttingProfile, evaluateCuttingReadiness, graduateCuttingProfile, isSeedProfile, evaluateSeedReadiness, graduateSeedProfile, isTreeSeed } from '../services/plantIntelligence/graduationService'; import { preserveStableInspectGuidance } from '../services/plantEnrichment'; import { img } from '../constants/images'; import { UiIcon, Card, Section, EmptyState, GardenChoiceChips, GardenMultiChips } from '../components/BaseUI'; import { FormTextField, FormImagePicker, ContextPhotoStack, SaveCancelActions } from '../components/FormUI'; import { SpaceCard } from '../components/GardenCards'; import { SettingsPanel, SettingsChip, SettingsSection } from '../components/SettingsPanel'; import { isOutdoorSpaceType, isIndoorSpaceType, formatGardenValue, formatSpaceType, formatSpaceLight, formatAmbientArtificialLight, formatHumidityProfile, formatSpaceStatusSummary, formatPlantType, formatLocationContext, formatIdentificationProvider, formatLightProfile, formatOutdoorSunTimings, formatDedicatedArtificialLight, formatSetupSpaceEnvironment, formatClimateAir, insideSpaceTypeOptions, outsideSpaceTypeOptions, cleanSpaceNotesForDisplay, getPotTypeOptionsForSpace, getEffectivePotTypeForSpace, formatAttentionCount, normalizeLightHoursInput, elevationOptions, roofTypeOptions, wallsOptions, openFacesOptions, glassTintOptions, formatWindowDirections, } from '../utils/formatters'; import { cleanPlantDisplayName, getSavedPlantDisplayName, getSamplePlantDisplayName, cleanScientificName, formatDetailDate, formatRelativeDate, formatPlantEventDate, getPlantEventTypeLabel, getSavedPlantEvents, getAllSavedPlantEvents, memoryCategoryGroups, plantMemoryEventOptions, plantMemoryDefaultTitles, buildPlantIntelligenceSummary, } from '../utils/plantHelpers'; import { maxContextPhotos, makeContextPhoto, normalizeContextPhotos, contextPhotoUris, formatPhotoCount, hasPlantSetupPhoto, hasSpaceEnvironmentPhoto, isSpaceEnvironmentComplete, } from '../utils/contextPhotoHelpers'; import { makeCameraContextExtra } from '../utils/photoSensorContext'; import type { AppSettings, ArtificialLightType, CareReminderPreferences, CareTaskActionState, CareTaskActionStatus, CareTaskDetailMetadata, CareTaskNotificationQuickAction, CareTaskSource, ChildAgeRange, GardenContextPhoto, GardenInfrastructureState, GardenSection, GardenForm, GardenDetail, GardenItem, HumidityProfile, LightProfile, OutdoorLightQuality, OutdoorSunTiming, PetAccessMode, PetType, PlantEvent, PlantEventType, PlantFilter, PlantFormState, PlantIntelligenceAction, PlantIntelligenceSummary, PlantKnowledgeCategory, PlantKnowledgeItem, PlantKnowledgeSummary, PlantMemoryEventType, PlantNotification, PlantNotificationState, PlantSetupProfile, PlantSort, RoomProfile, SamplePlant, SpaceSort, SpaceSummary, SpaceType, WishlistItem, WishlistVisibility, FieldAlbum, WindowDirection, PlantFormTarget, SetupFormTarget, SpaceFormTarget, DetailFormTarget, DetailHighlightTarget, SpaceFormState, SetupFormState, PlantEventFormState, ClimateDeviceFrequency, FeltEngagement, HeatType, } from '../types/appTypes'; import type { AnalysisStatus, PhotoAnalysisInput } from '../services/plantIntelligence/types'; import { savedGardenKey, sampleGardenKey, gardenPlants, plantFilterOptions, plantSortOptions, SAVED_PREFIX, plantTypeOptions, growthStageOptions, timeOwnedOptions, plantingMediumOptions, treatmentPreferenceOptions, spaceSortOptions, spaceTypeOptions, windowDirections, outdoorSunTimingOptions, outdoorLightQualityOptions, artificialLightTypeOptions, outdoorSpaceTypes, lightLevels, lightProfileOptions, humidityOptions, humidityProfileOptions, temperatureOptions, distanceOptions, potTypeOptions, potSizeOptions, drainageOptions, mediumOptions, topDressingOptions, wateringMethodOptions, plantedInOptions, customMediumComponents, inGroundKindOptions, wateringStyleOptions, hydroSetupOptions, hydroMediumOptions, } from '../types/appTypes'; import type { PlacementProximity, SavedPlantProfile, SavedPlantIdentificationSuggestion } from '../types/plantScan'; import { getSpaceArtwork } from '../assets/spaceArtwork'; import { loadGardenInfrastructure, saveGardenInfrastructure, loadSavedPlantProfiles, saveSavedPlantProfiles, loadWishlistItems, saveWishlistItems, loadAppSettings, saveAppSettings, updateSavedPlantProfile, createRoomProfile, updateRoomProfile, createPlantSetupProfile, updatePlantSetupProfile, normalizeStringArray, migratePlantSpaceLinks, createManualPlantProfile, createGardenId, } from '../services/gardenStorage'; import { findSpaceForPlant, getPlantsForSpace, normalizeGardenName, resolvePlantSpaceId as resolveStoredPlantSpaceId } from '../services/gardenSelectors'; import { persistPhoto, deletePhotosForPlant } from '../services/photoStorage'; import { analyzeAndStorePhoto, retryPhotoAnalysis, recoverStuckAnalyses } from '../services/plantIntelligence/photoIntelligenceExtractor'; import { buildInvestigationContextPacket, deleteIntelligenceProfile, addIntervention } from '../services/plantIntelligence/plantIntelligenceService'; import { makeEmptyPlantForm, makeEmptySpaceForm, makeEmptySetupForm, makeEmptyPlantEventForm, lightLevelFromProfile, humidityEstimateFromProfile, isInGroundPotType } from '../utils/formFactories'; import { createInvestigation, deleteInvestigation, linkPlantToInvestigation, addPhotosToInvestigation, getAllInvestigations, updateInvestigationStatus, updateInvestigation, appendProgressUpdate } from '../services/investigation'; import type { Investigation } from '../services/investigation'; import { RepotModal } from '../components/RepotModal'; import { RepotSetupModal } from '../components/RepotSetupModal'; import { buildCareContextPacket, fingerprintContextPacket, fingerprintContextPhotos, fingerprintContextSections, fingerprintContextSectionPhotos, getRepotGuidance, renderCareContextPacket } from '../services/care'; import type { RepotGuidance } from '../services/care'; import type { CareContextPacket, Cohort, GroupableCareAction } from '../types/care'; import { buildPlantMemoryContext, renderMemoryContextBlock } from '../services/plantIntelligence/memoryContext'; type GardenSelection = | { kind: 'sample'; plant: SamplePlant } | { kind: 'saved'; plant: SavedPlantProfile }; // Map raw symptom keys to friendly display labels (so chips never show clipped // raw keys like "webbin" / "discoloratio"). const SYMPTOM_LABELS: Record = { yellow_leaves: 'Yellow leaves', brown_tips: 'Brown tips', drooping: 'Drooping', wilting: 'Wilting', spots_patches: 'Spots or patches', holes: 'Holes in leaves', malformed_leaves: 'Malformed leaves', curled_leaves: 'Curled leaves', leaf_damage: 'Leaf damage', discoloration: 'Discoloration', browning: 'Browning / scorched', white_powder: 'White powder', sticky_residue: 'Sticky residue', bugs_pests: 'Bugs or pests', webbing: 'Webbing', mold_fungus: 'Mold or fungus', mushy_stems: 'Mushy stems / rot', leggy: 'Leggy / stretched', slow_growth: 'Slow growth', other: 'Other', }; function friendlySymptomLabel(key: string): string { if (SYMPTOM_LABELS[key]) return SYMPTOM_LABELS[key]; return key.replace(/_/g, ' '); } // ---- Placement proximity rows (Bekky, 2026-08-22, placement pass) ---- // Shows a three-level "Right next to / Nearby / Across the room" chip row for // each source of light & air the plant's space actually has: the window + any // climate device (airCon/heater/fan/humidifier). Absent devices get no row — // we never ask about something that isn't in the room. Unit-free, global app. const PROXIMITY_LEVEL_OPTIONS = [ { value: 'right_next_to', label: 'Right next to' }, { value: 'nearby', label: 'Nearby' }, { value: 'across_room', label: 'Across the room' }, ] as const; function buildProximityRows( room: RoomProfile | undefined, proximity: PlacementProximity, onChange: (next: PlacementProximity) => void ) { if (!room) return null; const dev = room.climateDevices; const rows: { key: keyof PlacementProximity; label: string }[] = []; // Window: only if the space is INDOOR and actually has one (windowDirection // !== 'none'). Outdoor spaces have no window to place a plant near, and // climate devices only propagate if listed in the plant's current space // (Bekky, 2026-08-23). const isOutdoorSpace = room.spaceType === 'balcony' || room.spaceType === 'patio' || room.spaceType === 'garden' || room.spaceType === 'orchard'; if (!isOutdoorSpace && room.windowDirections?.length && !room.windowDirections.includes('none')) rows.push({ key: 'window', label: 'Window' }); // Devices: only if actually used (Bekky, 2026-08-23). Air con / heater are now // {reach} / {reach, heatType}; fan/humidifier are plain frequency. Coerce legacy // shapes (boolean / bare frequency / old {freq}) so old saved spaces still work. const deviceReach = (d: unknown): string | undefined => { if (d && typeof d === 'object' && !Array.isArray(d)) { const o = d as { reach?: string; freq?: unknown }; if (o.reach) return o.reach; const f = o.freq === 'all_the_time' ? 'basically_always' : o.freq === 'never' ? 'never' : undefined; if (f) return f; return undefined; } return d === true ? 'basically_always' : d === false ? 'never' : (d as string) === 'never' ? 'never' : (d as string) || undefined; }; const deviceFreq = (d: unknown): string | undefined => { if (d && typeof d === 'object' && !Array.isArray(d)) return ((d as { freq?: unknown }).freq as string) || 'never'; return d === true ? 'all_the_time' : d === false ? 'never' : (d as string | undefined) || 'never'; }; if (dev?.airCon && deviceReach(dev.airCon) !== 'never' && deviceReach(dev.airCon)) rows.push({ key: 'airCon', label: 'Air con' }); if (dev?.heater && deviceReach(dev.heater) !== 'never' && deviceReach(dev.heater)) rows.push({ key: 'heater', label: 'Heater' }); if (dev?.fan && deviceFreq(dev.fan) !== 'never') rows.push({ key: 'fan', label: 'Fan' }); if (dev?.humidifier && deviceFreq(dev.humidifier) !== 'never') rows.push({ key: 'humidifier', label: 'Humidifier' }); if (rows.length === 0) return null; return ( {rows.map(row => ( {row.label} onChange({ ...proximity, [row.key]: level })} /> ))} ); } // Multi-delete wiggle (Bekky 2026-08-21): a gentle left-right rotation applied // to selected tiles while in multi-delete mode, like the classic "app editing" // wiggle. Uses an interruptible Animated.loop on a per-tile Animated.Value. function WiggleTile({ active, children }: { active: boolean; children: React.ReactNode }) { const anim = useRef(new Animated.Value(0)).current; useEffect(() => { if (active) { const loop = Animated.loop( Animated.sequence([ Animated.timing(anim, { toValue: 1, duration: 130, useNativeDriver: true, easing: Easing.linear }), Animated.timing(anim, { toValue: -1, duration: 260, useNativeDriver: true, easing: Easing.linear }), Animated.timing(anim, { toValue: 0, duration: 130, useNativeDriver: true, easing: Easing.linear }), ]) ); loop.start(); return () => loop.stop(); } anim.setValue(0); }, [active, anim]); const rotate = anim.interpolate({ inputRange: [-1, 0, 1], outputRange: ['-2.2deg', '0deg', '2.2deg'] }); return {children}; } export function ProfileScreen({ onAddPlant, onSavePlant, onUpdatePlant, onSpaceWaterAll, onDeletePlant, onSavePlantFromWishlist, onRemoveWishlistItem, savedPlants, wishlistItems, fieldAlbums, onSaveFieldDiaryEntry, onRemoveFieldDiaryEntry, onRemoveFieldAlbum, onSaveFieldDiaryEntryAlbum, selectedSavedPlantId, selectedSpaceDetailId, gardenResetToken, approximateLocationContext, carePreferences, weatherSnapshot, region, onSpaceDetailOpened, investigations, setInvestigations, openInvestigationId, onOpenInvestigationHandled, onOpenCaseTreatment, scrollToCareGuidancePlantId, onScrollToCareGuidanceHandled, HeaderComponent, ScreenComponent, gardenView, onGardenViewChange, }: { onAddPlant: () => void; onSavePlant: (profile: SavedPlantProfile) => void; onUpdatePlant: (plantId: string, patch: Partial) => void; /** SPACE-LEVEL WATER-ALL (Bekky, 2026-08-30): "I watered this whole space, * today" — an ANCHOR WRITE for every plant in the space ('manual' sweep * log), NOT a task completion. Works with zero pending tasks and * independently of the Intelligent Groupings toggle (the vacation case). * A `date` may be supplied (the group pencil passes the chosen date; the * Water-all button omits it = today). Args: spaceId + raw plantIds + action. */ onSpaceWaterAll?: (input: { spaceId: string; plantIds: string[]; action: 'water' | 'fertilize'; date?: string }) => void; onDeletePlant: (plantId: string) => void; onSavePlantFromWishlist: (item: WishlistItem) => void; onRemoveWishlistItem: (itemId: string) => void; savedPlants: SavedPlantProfile[]; wishlistItems: WishlistItem[]; /** Field Diary albums + mutations (Bekky, 2026-08-25). */ fieldAlbums: import('../types/garden').FieldAlbum[]; onSaveFieldDiaryEntry: (entry: import('../types/garden').FieldDiaryEntry, placeLabel: string) => void; onRemoveFieldDiaryEntry: (albumId: string, entryId: string) => void; onRemoveFieldAlbum: (albumId: string) => void; /** Add a completed excursion album to the field journal (Bekky, 2026-09-03). */ onSaveFieldDiaryEntryAlbum: (album: import('../types/garden').FieldAlbum) => void; selectedSavedPlantId?: string | null; selectedSpaceDetailId?: string | null; gardenResetToken: number; approximateLocationContext?: string | null; carePreferences?: import('../types/garden').CarePreferences; weatherSnapshot?: import('../types/care').WeatherSnapshot | null; /** Region context (expert review 2026-09-03, N4): passed so the manual * "Generate Custom Care Guidance" packet matches the auto-care packet * (which includes region). Without this, the two build DIFFERENT * contextPackets → different careInFlight dedup keys → a manual tap while * auto-care is in flight races two concurrent AI calls. */ region?: import('../types/care').RegionContext | undefined; onSpaceDetailOpened?: () => void; investigations: Investigation[]; setInvestigations: React.Dispatch>; /** External trigger: set to an investigation id to open it (e.g. from a Care-tab Check In). Cleared after handled. */ openInvestigationId?: string | null; /** Persisted Garden filter/sort (survives app open/close — Bekky, 2026-08-23). */ gardenView?: { plantFilter?: string; plantSort?: string } | null; onGardenViewChange?: (view: { plantFilter?: PlantFilter; plantSort?: PlantSort }) => void; onOpenInvestigationHandled?: () => void; /** CASE-TREATMENT (Bekky, 2026-09-02): opening the case-treatment multi-select * modal from the case-detail Treatment Plan (the "Log what I did" button). * The parent (App) owns the modal + its batch logic; this hands it the case. */ onOpenCaseTreatment?: (inv: Investigation) => void; /** External trigger: set to a plant id to open its detail AND scroll to Care Guidance (from Care-tab View Care). Cleared after handled. */ scrollToCareGuidancePlantId?: string | null; onScrollToCareGuidanceHandled?: () => void; HeaderComponent: React.ComponentType; ScreenComponent: React.ComponentType; }) { const Header = HeaderComponent; const Screen = ScreenComponent; const { height: windowHeight, width: windowWidth } = useWindowDimensions(); const insets = useSafeAreaInsets(); const [selectedPlant, setSelectedPlant] = useState(null); const [samplePlants, setSamplePlants] = useState(gardenPlants); const [gardenOrderKeys, setGardenOrderKeys] = useState(() => gardenPlants.map(plant => sampleGardenKey(plant.name))); const [gardenDataLoaded, setGardenDataLoaded] = useState(false); const [pendingDelete, setPendingDelete] = useState<{ key: string; label: string } | null>(null); // Multi-delete mode (Bekky 2026-08-21): long-press a plant tile to enter, // tap tiles to toggle selection (wiggle + red-minus badge), Delete all via // a confirmation modal, Cancel/Done to exit. selectionKeys = the garden // tile keys currently selected; null = not in multi-delete mode. const [multiDeleteKeys, setMultiDeleteKeys] = useState | null>(null); const [showMultiDeleteConfirm, setShowMultiDeleteConfirm] = useState(false); // Wishlist multi-delete (Bekky, 2026-08-25): long-press a wishlist tile to enter, // tap to toggle selection, Delete/Cancel to confirm. Selection by wishlist item id. const [wishlistDeleteKeys, setWishlistDeleteKeys] = useState | null>(null); const [showWishlistDeleteConfirm, setShowWishlistDeleteConfirm] = useState(false); // Field album multi-delete (Bekky, 2026-09-03): long-press a field album tile // to enter, tap to toggle selection, Remove/Cancel to confirm. Mirrors wishlist. const [fieldDeleteKeys, setFieldDeleteKeys] = useState | null>(null); const [showFieldDeleteConfirm, setShowFieldDeleteConfirm] = useState(false); const [pendingEventDelete, setPendingEventDelete] = useState(null); const [showSpacePicker, setShowSpacePicker] = useState(false); const [pendingSpaceId, setPendingSpaceId] = useState(null); const [showIdCorrection, setShowIdCorrection] = useState(false); const [idCorrectionName, setIdCorrectionName] = useState(''); const [idCorrectionScientific, setIdCorrectionScientific] = useState(''); const [isRenameMode, setIsRenameMode] = useState(false); const [changeIdSelectedIndex, setChangeIdSelectedIndex] = useState(null); const [changeIdSearch, setChangeIdSearch] = useState(''); const [showEditPlacement, setShowEditPlacement] = useState(false); const [placementDescriptionInput, setPlacementDescriptionInput] = useState(''); const [placementPhotoUri, setPlacementPhotoUri] = useState(null); // Up to 3 placement photos. placementPhotoUri stays in sync as the cover (first). const [placementPhotos, setPlacementPhotos] = useState([]); const [placementViewerUri, setPlacementViewerUri] = useState(null); // Per-device proximity to window + climate devices (Bekky, 2026-08-22, placement pass). const [placementProximity, setPlacementProximity] = useState({}); // Neighbor plants in the same space (Bekky, 2026-08-27, Chunk 1). Symmetric — // tagging A↔B is mutual. Space-scoped: only plants in the same space are pickable. const [neighborPlantIds, setNeighborPlantIds] = useState([]); // Standard form validation: placement needs a description to persist — if the // user taps Save without one, show a red pulsing exclamation on the field and // block the save (Bekky, 2026-08-23). const [placementNeedsText, setPlacementNeedsText] = useState(false); const placementWarnPulse = useRef(new Animated.Value(0)).current; // Generic photo-delete confirm: { kind, id, confirm } — covers placement/setup/environment const [pendingPhotoDelete, setPendingPhotoDelete] = useState<{ kind: 'placement' | 'setup' | 'environment'; id: string; confirm: () => void } | null>(null); const [showPhotoPicker, setShowPhotoPicker] = useState(false); const [pendingPhotoCallback, setPendingPhotoCallback] = useState<((uri: string) => void) | null>(null); const pendingPhotoCallbackRef = useRef<((uri: string, extra?: Partial>) => void) | null>(null); const [showCuttingModal, setShowCuttingModal] = useState(false); const [cuttingPlantName, setCuttingPlantName] = useState(''); // Photo guidance — "how to take a good photo" sheets (auto first-time + "?" trigger) const [photoGuidanceType, setPhotoGuidanceType] = useState(null); const photoGuidanceSeenRef = useRef>({ environment: false, placement: false, setup: false, progress: false }); const [cuttingPropagationInfo, setCuttingPropagationInfo] = useState(null); const [cuttingLoading, setCuttingLoading] = useState(false); const [cuttingConfirmation, setCuttingConfirmation] = useState<{ title: string; message: string; actionText?: string; onAction?: () => void } | null>(null); // Duplicate — light-themed PixieAlert confirmation (Bekky 2026-08-21: the // native Alert.alert rendered DARK on her Xiaomi; PixieAlert keeps it parchment). const [duplicateConfirmation, setDuplicateConfirmation] = useState<{ title: string; message: string } | null>(null); // Repot — light-themed guidance modal (Bekky, 2026-08-13) const [showRepotModal, setShowRepotModal] = useState(false); const [repotPlantName, setRepotPlantName] = useState(''); const [repotGuidance, setRepotGuidance] = useState(null); const [repotLoading, setRepotLoading] = useState(false); // On-demand guidance fetch (Bekky, 2026-08-15): don't call the AI until the // user asks for help. Saves API calls for people who don't need guidance. const [repotGuidanceRequested, setRepotGuidanceRequested] = useState(false); // Repot setup modal — dedicated simplified setup (soil/pot/photo) used ONLY // in the repot flow. "Save and Repot" records setup + repot in one action. const [showRepotSetupModal, setShowRepotSetupModal] = useState(false); const [repotSetupSaving, setRepotSetupSaving] = useState(false); // Reminder shown after a standalone repot (Path A): come back and update setup/memory. const [repotReminder, setRepotReminder] = useState<{ title: string; message: string } | null>(null); // Photo picked for the repot setup modal (passed via ref + tick so the modal // re-reads it on next render). const repotSetupPhotoRef = useRef(null); const [repotSetupPhotoTick, setRepotSetupPhotoTick] = useState(0); // Phase 2 — investigation outcome capture ("what worked / didn't") const [resolveOutcomeInv, setResolveOutcomeInv] = useState(null); // Delete a case — confirm then remove (lets users clear old cases and start fresh) const [pendingInvDelete, setPendingInvDelete] = useState(null); // Phase 3 — cutting graduation (AI detects + user confirms) const [graduatePlant, setGraduatePlant] = useState(null); // Phase 3 continuation — seed graduation (seed → sapling/young plant, user confirms) const [graduateSeedPlant, setGraduateSeedPlant] = useState(null); const [seedGraduationConfirm, setSeedGraduationConfirm] = useState<{ title: string; message: string } | null>(null); // Phase 4 — photo gallery + per-plant export const [galleryPhotos, setGalleryPhotos] = useState([]); const [gardenDetail, setGardenDetail] = useState(null); // Export UX — loading state + progress + quality choice const [exportBusy, setExportBusy] = useState(false); const [exportProgress, setExportProgress] = useState<{ phase: 'photos' | 'archive'; done: number; total: number } | null>(null); const [exportQualityChoice, setExportQualityChoice] = useState<{ kind: 'journal' | 'plant'; plantId?: string; plantName?: string } | null>(null); const exportProgressLabel = exportProgress ? exportProgress.phase === 'archive' ? 'Zipping…' : `Photos ${exportProgress.done}/${exportProgress.total}` : 'Preparing…'; const runExport = async (kind: 'journal' | 'plant', plantId: string | undefined, plantName: string | undefined, quality: 'original' | 'compressed') => { setExportBusy(true); setExportProgress(null); try { const { generateExport, generatePlantExport, saveExportToDisk } = await import('../services/dataExport'); const result = kind === 'journal' ? await generateExport({ photoQuality: quality, onProgress: setExportProgress }) : await generatePlantExport(plantId!, { photoQuality: quality, onProgress: setExportProgress }); if (!result.success || !result.archivePath) { setCuttingConfirmation({ title: 'Export failed', message: result?.error || 'Could not create export.' }); return; } const suggestedName = kind === 'journal' ? 'pixiesprout-journal-export.zip' : `pixiesprout-${(plantName || 'plant').replace(/\s+/g, '-').toLowerCase()}-export.zip`; const saved = await saveExportToDisk(result.archivePath, suggestedName); setCuttingConfirmation({ title: saved.saved ? (kind === 'journal' ? 'Journal saved!' : 'Export saved!') : 'Export ready', message: saved.saved ? (kind === 'journal' ? `Your plant journal was saved with ${result.manifest?.plantCount ?? 0} plants and ${result.manifest?.includedPhotos ?? 0} photos — it's yours to keep now.` : `${plantName || 'This plant'} was saved with ${result.manifest?.includedPhotos ?? 0} photos — it's yours to keep now.`) : (saved.error === 'Folder selection cancelled.' ? 'Folder selection was cancelled — nothing was saved. Tap Export again when you want to save it.' : `The export was created but couldn't be saved (${saved.error || 'unknown'}). Please try again.`), }); } catch (e) { const msg = e instanceof Error ? e.message : 'Unknown error'; setCuttingConfirmation({ title: 'Export error', message: msg }); } finally { setExportBusy(false); setExportProgress(null); } }; const [plantReturnDetail, setPlantReturnDetail] = useState(null); const [selectedInvestigation, setSelectedInvestigation] = useState(null); const [checkInTarget, setCheckInTarget] = useState<{ inv: Investigation; mode: 'progress' | 'secondLook' } | null>(null); const [plantFormReturnDetail, setPlantFormReturnDetail] = useState(null); const [gardenFormReturnDetail, setGardenFormReturnDetail] = useState(null); const [resumePlantFormAfterSpace, setResumePlantFormAfterSpace] = useState(false); const [gardenSection, setGardenSection] = useState('plants'); const [gardenSearch, setGardenSearch] = useState(''); // Phase 3 (2026-09-07): Seed Bank modal (guide + saved-seed collection). const [seedBankOpen, setSeedBankOpen] = useState(false); // Phase 3 (2026-09-07): the seed currently being planted (opens PlantSeedModal). const [plantingSeed, setPlantingSeed] = useState(null); const [plantFilter, setPlantFilter] = useState((gardenView?.plantFilter as PlantFilter) || 'all'); const [plantSort, setPlantSort] = useState((gardenView?.plantSort as PlantSort) || 'needs_care'); const [spaceSort, setSpaceSort] = useState('name_az'); const [openGardenDropdown, setOpenGardenDropdown] = useState<'filter' | 'sort' | null>(null); const [pendingFormScrollTarget, setPendingFormScrollTarget] = useState<'plant' | 'setup' | 'space' | null>(null); // The wishlist item currently open in detail view — lets the detail show // Add-to-garden / Add-cutting / Remove actions (Bekky, 2026-08-25). const [viewingWishlistItem, setViewingWishlistItem] = useState(null); // Mirrors whether the current detail was opened from the Wishlist gallery, so // back navigation returns to the Wishlist tab, not the Plants tab (Bekky, 2026-08-25). const viewingWishlistRef = useRef(false); // The Field Diary album currently open in its detail view (Bekky, 2026-08-25). const [viewingFieldAlbum, setViewingFieldAlbum] = useState(null); // The Field Diary entry currently being viewed (for the lightbox / detail). const [viewingFieldEntry, setViewingFieldEntry] = useState(null); // A selected lightbox photo index within the current entry (null = closed). const [lightboxIndex, setLightboxIndex] = useState(null); // Active excursion (Take a Walk / Take a Ride) — null when none running. const [activeExcursion, setActiveExcursion] = useState(null); // Live elapsed seconds for the running excursion's timer. const [excursionElapsed, setExcursionElapsed] = useState(0); // The excursion album currently being viewed in the map (null = closed). const [viewingExcursionMap, setViewingExcursionMap] = useState(null); // GPS override consent modal state. const [excursionConsent, setExcursionConsent] = useState<{ mode: import('../types/garden').ExcursionMode } | null>(null); // Confirm-to-exit modal state. const [excursionExitConfirm, setExcursionExitConfirm] = useState(false); const [rooms, setRooms] = useState([]); const [roomOrderKeys, setRoomOrderKeys] = useState([]); const [plantSetups, setPlantSetups] = useState([]); const [activeForm, setActiveForm] = useState(null); const [editingPlantId, setEditingPlantId] = useState(null); const [editingSpaceId, setEditingSpaceId] = useState(null); const [editingSetupPlantId, setEditingSetupPlantId] = useState(null); const [assigningSpaceId, setAssigningSpaceId] = useState(null); const [assignPlantKeys, setAssignPlantKeys] = useState([]); const [plantForm, setPlantForm] = useState(() => makeEmptyPlantForm()); const [spaceForm, setSpaceForm] = useState(() => makeEmptySpaceForm()); const [setupForm, setSetupForm] = useState(() => makeEmptySetupForm()); // SHARED CONTAINER (Bekky, 2026-09-04): modal to pick roommates (same-space // plants) or mark the roommate as not-yet-added. Opens from the setup form's // "Shared container" chip and from the plant detail's Container section. const [showSharedContainerModal, setShowSharedContainerModal] = useState(false); const [sharedContainerModalFor, setSharedContainerModalFor] = useState<'setup' | 'detail'>('setup'); // Delete-roommate modal (Bekky, 2026-09-04): when deleting a plant that has a // roommate, ask what happened to the roommate (3 exhaustive outcomes). const [deleteRoommateTarget, setDeleteRoommateTarget] = useState<{ plantId: string; roommateId: string } | null>(null); const [plantEventForm, setPlantEventForm] = useState(() => makeEmptyPlantEventForm()); const [savingGardenForm, setSavingGardenForm] = useState(false); const [spaceNameTouched, setSpaceNameTouched] = useState(false); const [spaceSaveAttempted, setSpaceSaveAttempted] = useState(false); const [intelligenceMessage, setIntelligenceMessage] = useState(null); const [intelligenceStatus, setIntelligenceStatus] = useState(null); const [intelligencePhotoType, setIntelligencePhotoType] = useState<'environment' | 'placement' | 'setup' | 'progress' | null>(null); // Per-section status so environment/placement/setup/progress each report their // OWN "learning/learned" message independently (Bekky, 2026-08-19) — the old // single shared state meant a same-refresh environment+setup run overwrote // each other and only one section showed its status. const [sectionMessages, setSectionMessages] = useState>>({}); const [intelligenceFailedInputs, setIntelligenceFailedInputs] = useState([]); const intelligenceTimerRef = useRef | null>(null); const intelligenceRetryRef = useRef | null>(null); const gardenScrollRef = useRef(null); const detailScrollRef = useRef(null); // Tracks the last selectedSavedPlantId we've already surfaced into selectedPlant, // so a stale id (e.g. from an earlier Home/scan view) can't re-select a plant // after that plant is deleted and savedPlants changes. const handledSavedPlantIdRef = useRef(null); const plantFormYRef = useRef(0); const setupFormYRef = useRef(0); const spaceFormYRef = useRef(0); // Header-anchored scroll targets (device-adaptive via measureLayout) const plantFormHeaderRef = useRef(null); const setupFormHeaderRef = useRef(null); const spaceFormHeaderRef = useRef(null); const eventFormHeaderRef = useRef(null); const environmentSectionRef = useRef(null); const setupSectionRef = useRef(null); const careGuidanceSectionRef = useRef(null); // Cadence wheel (Bekky, 2026-08-13, reworded 2026-08-30): tap a Water/Fertilize/ // Inspect row in Care Guidance to LOG the last-done DATE for this plant — // no cadence override exists (Bekky's final ruling: "I got rid of it and I'm // not bringing it back"). const [cadenceOverride, setCadenceOverride] = useState<{ action: 'water' | 'fertilize' | 'inspect'; currentDays: number; currentLastDone?: string | null } | null>(null); // SPACE-PROFILE COHORT CARD (Bekky, 2026-08-30, chunk B-adjacent): which // cohort rows are expanded in the space detail Care Groups card, and which // cohort (if any) has its group pencil open. Pencil edits the GROUP's shared // last-watered date only (Option A) — names/members stay the AI's department. const [cohortDateEdit, setCohortDateEdit] = useState<{ spaceId: string; cohortId: string; action: 'water' | 'fertilize'; cohortName: string; plantIds: string[] } | null>(null); // COHORT FERTILIZER EDIT (Bekky, 2026-09-02): editing the fertilizer for a // fertilize cohort opens a small modal that CONFIRMS this updates every member // plant, then writes `fertilizer` to each member's setup (uniform). const [cohortFertilizerEdit, setCohortFertilizerEdit] = useState<{ cohortName: string; plantIds: string[] } | null>(null); const [cohortFertilizerInput, setCohortFertilizerInput] = useState(''); // "Apply fertilizer to the whole cohort too?" Yes/No on the plant setup form // (Bekky, 2026-09-02 rework). Transient — on Yes, the plant's fertilizer is // propagated to its fertilize cohort at save. Not persisted. const [fertilizerApplyGroup, setFertilizerApplyGroup] = useState(null); /** Write `value` to a plant's setup `fertilizer` field (creates setup if missing). */ const setPlantFertilizer = (plantKey: string, value: string) => { const existing = setupByPlantId.get(plantKey); const trimmed = value.trim(); if (existing) { setPlantSetups(current => current.map(setup => setup.id === existing.id ? updatePlantSetupProfile(setup, { fertilizer: trimmed || undefined }) : setup)); } else { setPlantSetups(current => [createPlantSetupProfile({ plantId: plantKey, fertilizer: trimmed || undefined, dedicatedArtificialLightTypes: [] }), ...current]); } }; // SPACE DETAIL COLLAPSIBLE TILES (Bekky, 2026-08-30): Space Details + // Assigned Plants collapse via chevron, PERSISTED across app open/close. // Stored on the ROOM (room.collapsedTiles) so ProfileScreen's existing // saveGardenInfrastructure effect writes it through automatically. // Value = list of card keys currently COLLAPSED ('details' | 'plants'). const isSpaceTileCollapsed = (room: RoomProfile, key: string) => (room.collapsedTiles || []).includes(key); const toggleSpaceTile = (room: RoomProfile, key: string) => { const current = room.collapsedTiles || []; const next = current.includes(key) ? current.filter(k => k !== key) : [...new Set([...current, key])]; setRooms(cur => cur.map(r => r.id === room.id ? { ...r, collapsedTiles: next, updatedAt: new Date().toISOString() } : r)); }; // COHORT ROW EXPANSION (Bekky, 2026-08-30): persisted on the room alongside // collapsedTiles — user's expanded cohort rows survive app restarts. const isCohortExpanded = (room: RoomProfile, ck: string) => (room.expandedCohortKeys || []).includes(ck); const toggleCohortExpanded = (room: RoomProfile, ck: string) => { const current = room.expandedCohortKeys || []; const next = current.includes(ck) ? current.filter(k => k !== ck) : [...new Set([...current, ck])]; setRooms(cur => cur.map(r => r.id === room.id ? { ...r, expandedCohortKeys: next, updatedAt: new Date().toISOString() } : r)); }; const [originatingSection, setOriginatingSection] = useState(null); const pendingDetailFieldTargetRef = useRef(null); const pendingDetailHighlightTargetRef = useRef(null); const detailHighlightTimerRef = useRef | null>(null); const plantFieldYRef = useRef>>({}); const setupFieldYRef = useRef>>({}); const spaceFieldYRef = useRef>>({}); const progressSectionYRef = useRef(0); const progressSectionRef = useRef(null); const addProgressPhotoButtonRef = useRef(null); const eventFormCardRef = useRef(null); const [detailHighlightKey, setDetailHighlightKey] = useState(null); const gardenSectionHistoryRef = useRef([]); const gardenPagerRef = useRef(null); const gardenPagerScrollingRef = useRef(false); // When the pager remounts (after a case-detail/detail-screen early-return), the // width isn't measured on first render — so a programmatic scroll can't compute // x. Store the target section here and re-scroll once onLayout measures width. const pendingScrollToSectionRef = useRef(null); // Only snap the pager to the current section on its FIRST layout after mount, // not on every re-layout (which would fight a user swipe mid-scroll). const gardenPagerInitialSnapRef = useRef(false); const gardenPagerWidthRef = useRef(0); // The garden content has paddingHorizontal:18, so the pager viewport is // windowWidth - 36, NOT windowWidth. Page width + snap math must use the // measured pager width (Bekky, 2026-08-15: pages were misaligned + drifted). const [gardenPagerWidth, setGardenPagerWidth] = useState(windowWidth - 36); // Garden section order — matches the segment bar (Plants → Spaces → Kit → // Wishlist → Field → Cases). Used for horizontal paging. Field sits next to // Wishlist, Cases is last (Bekky, 2026-08-25). const GARDEN_SECTION_ORDER: GardenSection[] = ['plants', 'spaces', 'wishlist', 'field', 'investigations']; const plantLongPressSuppressedRef = useRef(false); const spaceReturnPlantRef = useRef(null); const latestGardenStateRef = useRef({ plants: [] as { id: string; name: string; spaceId?: string | null }[], spaces: [] as RoomProfile[] }); const savedPlantById = new Map(savedPlants.map(plant => [plant.id, plant])); const samplePlantByName = new Map(samplePlants.map(plant => [plant.name, plant])); // Seed-bank-only entries (Bekky, 2026-09-08): a seed saved to the Seed Bank // (scanMode 'seed', NO growthStage) lives ONLY in the Seed Bank modal — it // must NOT appear as a Garden tile. Only PLANTED seeds (growthStage set, e.g. // 'seedling' via Start Growing) are Garden citizens. This prevents the // "double save" where Save to Seed Bank + Start Growing created two tiles. const isSeedBankOnly = (plant: SavedPlantProfile) => plant.scanMode === 'seed' && !plant.growthStage; const orderedGardenItems = gardenOrderKeys .map(key => { if (key.startsWith(SAVED_PREFIX)) { const plant = savedPlantById.get(key.slice(SAVED_PREFIX.length)); return plant && !isSeedBankOnly(plant) ? { key, kind: 'saved', plant } : null; } if (key.startsWith('sample:')) { const plant = samplePlantByName.get(key.slice(7)); return plant ? { key, kind: 'sample', plant } : null; } return null; }) .filter((item): item is GardenItem => Boolean(item)); const setupByPlantId = new Map(plantSetups.map(setup => [setup.plantId, setup])); const activeFilterLabel = gardenSection === 'plants' ? plantFilterOptions.find(option => option.value === plantFilter)?.label || 'All' : 'All'; const activeSortLabel = gardenSection === 'plants' ? plantSortOptions.find(option => option.value === plantSort)?.label || 'By Space' : gardenSection === 'spaces' ? spaceSortOptions.find(option => option.value === spaceSort)?.label || 'Name A-Z' : 'Newest'; const getPlantItemName = (item: GardenItem) => item.kind === 'saved' ? getSavedPlantDisplayName(item.plant) : getSamplePlantDisplayName(item.plant); const getPlantItemCreatedAt = (item: GardenItem) => item.kind === 'saved' ? item.plant.createdAt : '2026-01-01T00:00:00.000Z'; const getPlantItemStatus = (item: GardenItem) => item.kind === 'saved' ? 'Recently Identified' : item.plant.status; const getPlantItemCare = (item: GardenItem) => item.kind === 'saved' ? 'Scan saved' : item.plant.care; // Local care-date helpers (mirror App.tsx getCareDateKey / getCareDayOffset). const gardenCareTodayKey = () => { const date = new Date(); const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }; const gardenCareDayOffset = (dateKey?: string) => { if (!dateKey) return 1; const today = new Date(`${gardenCareTodayKey()}T00:00:00`); const target = new Date(`${dateKey}T00:00:00`); if (Number.isNaN(target.getTime())) return 1; return Math.max(1, Math.round((target.getTime() - today.getTime()) / 86400000)); }; // Needs care = ANY scheduled water/fertilize/inspect task is due today, // overdue, or within the next 3 days (the app allows completing 3 days early). const plantItemNeedsCare = (item: GardenItem) => { if (item.kind !== 'saved') return false; const schedule = item.plant.careSchedule; if (!schedule?.scheduled?.length) return false; const todayKey = gardenCareTodayKey(); for (const task of schedule.scheduled) { const lastDone = schedule.lastDone?.[task.action]; const everyDays = Math.max(1, task.everyDays); // Anchor: lastDone + cadence, else first due at today + cadence. const dueDate = lastDone ? new Date(`${lastDone}T00:00:00`) : new Date(`${todayKey}T00:00:00`); if (Number.isNaN(dueDate.getTime())) continue; dueDate.setDate(dueDate.getDate() + everyDays); const y = dueDate.getFullYear(); const m = String(dueDate.getMonth() + 1).padStart(2, '0'); const d = String(dueDate.getDate()).padStart(2, '0'); const dueKey = `${y}-${m}-${d}`; const offset = gardenCareDayOffset(dueKey < todayKey ? todayKey : dueKey); if (offset <= 3) return true; } return false; }; // Open cases = any investigation linked to this plant that's still open/monitoring. const plantItemHasOpenCase = (item: GardenItem) => { if (item.kind !== 'saved') return false; return investigations.some(inv => inv.plantId === item.plant.id && (inv.status === 'open' || inv.status === 'monitoring')); }; // Last-watered key: most recent water careLog doneDate (or careSchedule.lastDone.water), // else null. Used by the "Last Watered" sort (most recently watered first). const getPlantItemLastWateredKey = (item: GardenItem): string | null => { if (item.kind !== 'saved') return null; const schedule = item.plant.careSchedule; if (!schedule) return null; if (schedule.lastDone?.water) return schedule.lastDone.water; let latest: string | null = null; for (const log of schedule.careLog || []) { if (log.action !== 'water') continue; const d = log.completedDate || log.triggerDate; if (d && (!latest || d > latest)) latest = d; } return latest; }; const getPlantItemId = (item: GardenItem) => item.kind === 'saved' ? savedGardenKey(item.plant.id) : sampleGardenKey(item.plant.name); const getPlantItemSpaceId = (item: GardenItem) => item.kind === 'saved' ? item.plant.spaceId || null : item.plant.spaceId || null; const getPlantItemSpaceSortKey = (item: GardenItem) => { const spaceId = getPlantItemSpaceId(item); if (!spaceId) return { rank: Number.MAX_SAFE_INTEGER, label: 'Unassigned' }; const room = rooms.find(space => space.id === spaceId); return { rank: 0, label: room?.name || 'Unassigned' }; }; const getSavedPlantImageUri = (plant: SavedPlantProfile) => plant.photoUri || plant.identification?.imageUri || plant.identification?.imageUris?.[0] || ''; // Launch image policy: // Plants display user-selected photos or scan photos only. // Generated watercolor artwork is not part of launch. // Fallback must remain neutral. const getLaunchPlantImageSource = (plant?: SavedPlantProfile | null): { source: ImageSourcePropType | null; resizeMode: 'cover' } => { const imageUri = plant ? getSavedPlantImageUri(plant) : ''; return { source: imageUri ? { uri: imageUri } : null, resizeMode: 'cover' }; }; const getPlantItemLaunchImage = (item: GardenItem) => item.kind === 'saved' ? getLaunchPlantImageSource(item.plant) : getLaunchPlantImageSource(null); const getPlantItemSetup = (item: GardenItem) => setupByPlantId.get(getPlantItemId(item)); const getSpaceNameById = (spaceId?: string | null) => spaceId ? rooms.find(room => room.id === spaceId)?.name : undefined; const resolvePlantSpaceId = (plant: SavedPlantProfile) => resolveStoredPlantSpaceId(plant, rooms); const getSavedPlantsForSpace = (spaceId: string) => getPlantsForSpace(spaceId, savedPlants); const getSamplePlantsForSpace = (spaceId: string) => getPlantsForSpace(spaceId, samplePlants); const getAssignedGardenItemsForSpace = (spaceId: string): GardenItem[] => [ ...getSavedPlantsForSpace(spaceId).filter(p => !isSeedBankOnly(p)).map(plant => ({ key: savedGardenKey(plant.id), kind: 'saved' as const, plant })), ...getSamplePlantsForSpace(spaceId).map(plant => ({ key: sampleGardenKey(plant.name), kind: 'sample' as const, plant })), ].sort((a, b) => getPlantItemName(a).localeCompare(getPlantItemName(b))); const plantNeedsCare = (item: GardenItem) => { // TODO: Replace text matching with a dedicated care-state field when one exists. const status = getPlantItemStatus(item).toLowerCase(); const care = getPlantItemCare(item).toLowerCase(); return status.includes('needs') || status.includes('attention') || status.includes('overdue') || care.includes('today') || care.includes('check') || care.includes('pest') || care.includes('overdue'); }; const summarizeSpace = (items: GardenItem[]): SpaceSummary => { const needsCareCount = items.filter(plantNeedsCare).length; if (!items.length) return { plantCount: 0, needsCareCount: 0, statusLabel: 'Empty Space', statusTone: 'empty' }; if (needsCareCount) return { plantCount: items.length, needsCareCount, statusLabel: 'Needs Attention', statusTone: 'needs' }; return { plantCount: items.length, needsCareCount: 0, statusLabel: 'All Good', statusTone: 'good' }; }; const textMatches = (value: string) => value.toLowerCase().includes(gardenSearch.trim().toLowerCase()); const summarizeGardenPlants = () => [ ...savedPlants.filter(p => !isSeedBankOnly(p)).map(plant => ({ id: plant.id, name: getSavedPlantDisplayName(plant), spaceId: plant.spaceId })), ...samplePlants.map(plant => ({ id: plant.id, name: getSamplePlantDisplayName(plant), spaceId: plant.spaceId })), ]; const filteredPlantItems = orderedGardenItems.filter(item => { const setup = getPlantItemSetup(item); const matchesSearch = !gardenSearch.trim() || textMatches(getPlantItemName(item)) || textMatches(getPlantItemCare(item)) || (item.kind === 'saved' && textMatches(item.plant.scientificName)); if (!matchesSearch) return false; if (plantFilter === 'all') return true; if (plantFilter === 'needs_care') return plantItemNeedsCare(item); if (plantFilter === 'missing_setup') return !setup; if (plantFilter === 'open_cases') return plantItemHasOpenCase(item); if (plantFilter === 'seeds') return item.kind === 'saved' && item.plant.scanMode === 'seed'; return true; }); const visiblePlantItems = [...filteredPlantItems].sort((a, b) => { if (plantSort === 'by_space') { const aSpace = getPlantItemSpaceSortKey(a); const bSpace = getPlantItemSpaceSortKey(b); if (aSpace.rank !== bSpace.rank) return aSpace.rank - bSpace.rank; return aSpace.label.localeCompare(bSpace.label) || getPlantItemName(a).localeCompare(getPlantItemName(b)); } if (plantSort === 'name_az') return getPlantItemName(a).localeCompare(getPlantItemName(b)); if (plantSort === 'recently_added') return getPlantItemCreatedAt(b).localeCompare(getPlantItemCreatedAt(a)); if (plantSort === 'needs_care') { const aNeeds = plantItemNeedsCare(a) ? 0 : 1; const bNeeds = plantItemNeedsCare(b) ? 0 : 1; return aNeeds - bNeeds || getPlantItemName(a).localeCompare(getPlantItemName(b)); } if (plantSort === 'last_watered') { const aLast = getPlantItemLastWateredKey(a); const bLast = getPlantItemLastWateredKey(b); // Most recently watered first; plants with no record sort to the end. if (aLast === null && bLast === null) return getPlantItemName(a).localeCompare(getPlantItemName(b)); if (aLast === null) return 1; if (bLast === null) return -1; return bLast.localeCompare(aLast); } return 0; }); const visibleGardenPlantItems = gardenDataLoaded ? visiblePlantItems : []; const orderedRooms = roomOrderKeys.map(id => rooms.find(room => room.id === id)).filter((room): room is RoomProfile => Boolean(room)); const visibleRooms = [...orderedRooms] .filter(room => !gardenSearch.trim() || textMatches(room.name) || textMatches(formatSpaceType(room.spaceType)) || textMatches(formatGardenValue(room.lightLevel))) .sort((a, b) => { if (spaceSort === 'name_az') return a.name.localeCompare(b.name); if (spaceSort === 'recently_added') return b.createdAt.localeCompare(a.createdAt); if (spaceSort === 'most_plants') { const countFor = (roomId: string) => getAssignedGardenItemsForSpace(roomId).length; return countFor(b.id) - countFor(a.id) || a.name.localeCompare(b.name); } return 0; }); const visibleWishlistItems = [...wishlistItems] .filter(item => !gardenSearch.trim() || textMatches(item.commonName) || textMatches(item.scientificName || '') || textMatches(item.notes || '')) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); const spaceGroupOrder = [...rooms].sort((a, b) => a.name.localeCompare(b.name)); const buildPlantSpaceGroup = (room: RoomProfile) => ({ key: room.id, title: room.name, items: visibleGardenPlantItems .filter(item => getPlantItemSpaceId(item) === room.id) .sort((a, b) => getPlantItemName(a).localeCompare(getPlantItemName(b))), }); const unassignedPlantItems = visibleGardenPlantItems .filter(item => !getPlantItemSpaceId(item) || !rooms.some(room => room.id === getPlantItemSpaceId(item))) .sort((a, b) => getPlantItemName(a).localeCompare(getPlantItemName(b))); const visibleGardenPlantGroups = plantSort === 'by_space' ? [ { key: 'indoor', title: 'INDOOR', spaces: spaceGroupOrder .filter(room => isIndoorSpaceType(room.spaceType)) .map(buildPlantSpaceGroup) .filter(group => group.items.length > 0), }, { key: 'outdoor', title: 'OUTDOOR', spaces: spaceGroupOrder .filter(room => isOutdoorSpaceType(room.spaceType)) .map(buildPlantSpaceGroup) .filter(group => group.items.length > 0), }, { key: 'unassigned', title: 'UNASSIGNED', spaces: unassignedPlantItems.length ? [{ key: 'unassigned-items', title: 'Unassigned', items: unassignedPlantItems }] : [], }, ].filter(group => group.spaces.length > 0) : []; // Grouping headers for the non-"By Space" sorts so the garden isn't a flat // wall of tiles (Bekky, 2026-08-23): Needs Care → "Needs Care"/"All Good"; // Name A-Z → first-letter bands; Last Watered → recency bands. "Recently // Added" stays flat. Returns [] when the sort shouldn't group. const lastWateredBandKey = (item: GardenItem): string | null => { const d = getPlantItemLastWateredKey(item); if (!d) return null; const days = gardenDaysSinceDateKey(d); if (days <= 6) return 'This week'; if (days <= 28) return '2–4 weeks ago'; return 'Older'; }; const gardenDaysSinceDateKey = (dateKey: string) => { const today = new Date(`${gardenCareTodayKey()}T00:00:00`); const target = new Date(`${dateKey}T00:00:00`); if (Number.isNaN(target.getTime())) return Number.POSITIVE_INFINITY; return Math.max(0, Math.round((today.getTime() - target.getTime()) / 86400000)); }; const buildSortGroupSections = () => { if (plantSort === 'needs_care') { const needs = visibleGardenPlantItems.filter(plantItemNeedsCare); const allGood = visibleGardenPlantItems.filter(i => !plantItemNeedsCare(i)); return [ { key: 'needs', title: 'NEEDS CARE', items: needs }, { key: 'good', title: 'ALL GOOD', items: allGood }, ].filter(g => g.items.length > 0); } if (plantSort === 'name_az') { const byLetter = new Map(); for (const item of visibleGardenPlantItems) { const name = getPlantItemName(item); const letter = (name[0] || '#').toUpperCase(); const arr = byLetter.get(letter) || []; arr.push(item); byLetter.set(letter, arr); } return Array.from(byLetter.entries()).map(([letter, items]) => ({ key: letter, title: letter.toUpperCase(), items })); } if (plantSort === 'last_watered') { const bands: { key: string; title: string; items: typeof visibleGardenPlantItems }[] = []; const order = ['This week', '2–4 weeks ago', 'Older', 'Never watered']; const buckets = new Map(); for (const item of visibleGardenPlantItems) { const band = lastWateredBandKey(item) || 'Never watered'; const arr = buckets.get(band) || []; arr.push(item); buckets.set(band, arr); } for (const title of order) { if (buckets.has(title) && buckets.get(title)!.length) bands.push({ key: title, title: title.toUpperCase(), items: buckets.get(title)! }); } return bands; } return []; }; const gardenGroupSections = plantSort === 'by_space' ? [] : buildSortGroupSections(); const visibleRoomGroups = [ { key: 'inside', title: 'Inside', rooms: visibleRooms.filter(room => isIndoorSpaceType(room.spaceType)).sort((a, b) => a.name.localeCompare(b.name)), }, { key: 'outside', title: 'Outside', rooms: visibleRooms.filter(room => isOutdoorSpaceType(room.spaceType)).sort((a, b) => a.name.localeCompare(b.name)), }, ].filter(group => group.rooms.length > 0); // External trigger: open a specific investigation (e.g. a Care-tab "Check In"). useEffect(() => { if (!openInvestigationId) return; const inv = investigations.find(i => i.investigationId === openInvestigationId); if (inv) { setSelectedInvestigation(inv); } onOpenInvestigationHandled?.(); }, [openInvestigationId]); // Toggle a visit's collapsed state on the open investigation. Persists per-case // via Investigation.collapsedPoints[] (saved to investigations.json), so it // survives app restarts — same pattern as plant-detail collapsedSections. const toggleVisitCollapsed = (pointId: string) => { const inv = selectedInvestigation; if (!inv) return; const current = inv.collapsedPoints || []; const next = current.includes(pointId) ? current.filter(id => id !== pointId) : [...current, pointId]; setSelectedInvestigation(currentInv => currentInv ? { ...currentInv, collapsedPoints: next } : currentInv); setInvestigations(currentList => currentList.map(ci => ci.investigationId === inv.investigationId ? { ...ci, collapsedPoints: next } : ci )); updateInvestigation(inv.investigationId, { collapsedPoints: next }).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] updateInvestigation collapsedPoints failed', err); }); }; const isVisitCollapsed = (pointId: string) => !!selectedInvestigation && (selectedInvestigation.collapsedPoints || []).includes(pointId); // External trigger: open a plant's detail AND scroll to Care Guidance // (e.g. a Care-tab "View Care"). The plant is surfaced via selectedSavedPlantId; // here we reset the once-guard so a re-tap of the same plant re-surfaces it, // then scroll to the guidance section once the detail is rendered. useEffect(() => { if (!scrollToCareGuidancePlantId) return; // Force re-surface: clear the once-guard so the surfacing effect re-runs // even if this plant was already viewed (otherwise a re-tap stays on Garden). handledSavedPlantIdRef.current = null; const savedPlant = savedPlants.find(p => p.id === scrollToCareGuidancePlantId); if (savedPlant) { setSelectedPlant({ kind: 'saved', plant: savedPlant }); } // Wait for the detail to render, then scroll to the Care Guidance section. setTimeout(() => { scrollToSection(careGuidanceSectionRef, 120); }, 350); onScrollToCareGuidanceHandled?.(); }, [scrollToCareGuidancePlantId]); useEffect(() => { let active = true; loadGardenInfrastructure().then(state => { if (!active) return; const loadedRooms = state.rooms.length ? state.rooms : []; const loadedSamplePlants = state.samplePlantsSeeded ? state.samplePlants.map(storedPlant => { const defaultPlant = gardenPlants.find(plant => plant.id === storedPlant.id || plant.name === storedPlant.name); return defaultPlant ? { ...defaultPlant, ...storedPlant, image: defaultPlant.image } : null; }).filter((plant): plant is SamplePlant => Boolean(plant)) : gardenPlants; setRooms(loadedRooms); setRoomOrderKeys(state.roomOrderKeys.length ? state.roomOrderKeys : loadedRooms.map(room => room.id)); setPlantSetups(state.plantSetups); setSamplePlants(loadedSamplePlants); setGardenOrderKeys(current => state.gardenOrderKeys.length ? state.gardenOrderKeys : current); setGardenDataLoaded(true); }); // Load investigations getAllInvestigations().then(loaded => { if (!active) return; setInvestigations(loaded); }).catch(() => {}); return () => { active = false; }; }, []); useEffect(() => { if (!gardenDataLoaded) return; saveGardenInfrastructure({ rooms, roomOrderKeys, plantSetups, samplePlants: samplePlants.map(plant => ({ id: plant.id, name: plant.name, status: plant.status, care: plant.care, label: plant.label, spaceId: plant.spaceId ?? null, })), gardenOrderKeys, samplePlantsSeeded: true, ...({ ['sample' + 'Water' + 'colorIcons']: {} } as Record>), } as GardenInfrastructureState).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] saveGardenInfrastructure failed', err); }); }, [gardenDataLoaded, rooms, roomOrderKeys, plantSetups, samplePlants, gardenOrderKeys]); useEffect(() => { latestGardenStateRef.current = { plants: summarizeGardenPlants(), spaces: rooms }; }); useEffect(() => { if (!gardenDataLoaded) return; }, [gardenDataLoaded]); useEffect(() => () => { }, []); useEffect(() => { if (!selectedSavedPlantId) { // Selection cleared (e.g. leaving the profile tab) — reset so a later // re-open of the same plant still surfaces it. handledSavedPlantIdRef.current = null; return; } // Only surface a NEW selectedSavedPlantId once. If it's the same id we already // handled (or a stale id from an earlier view), don't re-select — otherwise a // savedPlants change (e.g. after deleting a different plant) would snap back // to the previously-viewed plant instead of returning to My Garden. if (handledSavedPlantIdRef.current === selectedSavedPlantId) return; const savedPlant = savedPlants.find(plant => plant.id === selectedSavedPlantId); if (savedPlant) { handledSavedPlantIdRef.current = selectedSavedPlantId; setSelectedPlant({ kind: 'saved', plant: savedPlant }); } }, [selectedSavedPlantId, savedPlants]); useEffect(() => { if (!selectedSpaceDetailId || !gardenDataLoaded || !rooms.some(room => room.id === selectedSpaceDetailId)) return; setSelectedPlant(null); setGardenSection('spaces'); setActiveForm(null); setAssigningSpaceId(null); setOpenGardenDropdown(null); setGardenDetail({ kind: 'space', id: selectedSpaceDetailId }); onSpaceDetailOpened?.(); }, [selectedSpaceDetailId, gardenDataLoaded, rooms, onSpaceDetailOpened]); // Reset the plant selection when the Garden tab is re-tapped (gardenResetToken // increments). Skip the FIRST run: on mount this effect would otherwise wipe a // plant surfaced via selectedSavedPlantId (e.g. a Care-tab "View Care"), because // effects run in order and this one (declared after the surfacing effect) would // null it out immediately. Only reset on an actual token change, not on mount. const gardenResetFirstRun = useRef(true); useEffect(() => { if (gardenResetFirstRun.current) { gardenResetFirstRun.current = false; return; } setSelectedPlant(null); }, [gardenResetToken]); useEffect(() => { const validKeys = [ ...savedPlants.map(plant => savedGardenKey(plant.id)), ...samplePlants.map(plant => sampleGardenKey(plant.name)), ]; setGardenOrderKeys(current => { const kept = current.filter(key => validKeys.includes(key)); const missing = validKeys.filter(key => !kept.includes(key)); return [...missing, ...kept]; }); }, [savedPlants, samplePlants]); useEffect(() => { const validKeys = rooms.map(room => room.id); setRoomOrderKeys(current => { const kept = current.filter(key => validKeys.includes(key)); const missing = validKeys.filter(key => !kept.includes(key)); return [...missing, ...kept]; }); }, [rooms]); useEffect(() => { if (selectedPlant?.kind !== 'saved') return; if (selectedPlant.plant.source === 'wishlist') return; // don't sync temporary wishlist detail plants const refreshedPlant = savedPlants.find(plant => plant.id === selectedPlant.plant.id); if (refreshedPlant && refreshedPlant !== selectedPlant.plant) { setSelectedPlant({ kind: 'saved', plant: refreshedPlant }); } else if (!refreshedPlant) { setSelectedPlant(null); } }, [savedPlants, selectedPlant]); useEffect(() => { if (selectedPlant?.kind !== 'sample') return; const refreshedPlant = samplePlants.find(plant => plant.name === selectedPlant.plant.name); if (refreshedPlant && refreshedPlant !== selectedPlant.plant) { setSelectedPlant({ kind: 'sample', plant: refreshedPlant }); } else if (!refreshedPlant) { setSelectedPlant(null); } }, [samplePlants, selectedPlant]); useEffect(() => { if (!gardenDataLoaded || !rooms.length || !savedPlants.length) return; const migration = migratePlantSpaceLinks(savedPlants, rooms); migration.plants.forEach(migratedPlant => { const originalPlant = savedPlants.find(plant => plant.id === migratedPlant.id); if (!originalPlant) return; const setupSpaceId = plantSetups.find(setup => setup.plantId === savedGardenKey(originalPlant.id))?.roomId; const finalSpaceId = migratedPlant.spaceId || (setupSpaceId && rooms.some(room => room.id === setupSpaceId) ? setupSpaceId : null); const originalMedium = normalizeStringArray(originalPlant.plantingMedium); const migratedMedium = normalizeStringArray(migratedPlant.plantingMedium); const changed = originalPlant.spaceId !== finalSpaceId || originalPlant.assignedSpaceId !== (finalSpaceId || undefined) || Boolean(originalPlant.spaceName || originalPlant.space || originalPlant.location) || originalMedium.join('|') !== migratedMedium.join('|') || !originalPlant.treatmentPreferences || !originalPlant.careGoals || !originalPlant.commonIssues; if (!changed) return; onUpdatePlant(originalPlant.id, { spaceId: finalSpaceId, assignedSpaceId: finalSpaceId || undefined, spaceName: undefined, space: undefined, location: undefined, plantingMedium: migratedMedium, treatmentPreferences: migratedPlant.treatmentPreferences, careGoals: migratedPlant.careGoals, commonIssues: migratedPlant.commonIssues, locationContext: finalSpaceId ? 'home' : 'unsure', }); }); samplePlants.forEach(samplePlant => { if (samplePlant.spaceId) return; const setupSpaceId = plantSetups.find(setup => setup.plantId === sampleGardenKey(samplePlant.name))?.roomId; if (!setupSpaceId || !rooms.some(room => room.id === setupSpaceId)) return; setSamplePlants(current => current.map(plant => plant.name === samplePlant.name ? { ...plant, spaceId: setupSpaceId } : plant)); }); }, [gardenDataLoaded, rooms, savedPlants, samplePlants, plantSetups]); const chooseGardenPhotoFromLibrary = async (onPhoto: (uri: string, extra?: Partial>) => void) => { try { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { Alert.alert('Photo access needed', 'Allow photo access to choose an image for this item.'); return; } const response = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.82, }); if (!response.canceled && response.assets[0]?.uri) { // Gallery photos carry source='gallery' and NO heading/altitude — those // are capture-time data that would be stale for an imported photo. const galleryExtra: Partial> = { source: 'gallery' }; // Persist photo to permanent storage before returning URI try { const record = await persistPhoto(response.assets[0].uri, { photoType: 'memory' }); onPhoto(record.activeUri, galleryExtra); } catch { onPhoto(response.assets[0].uri, galleryExtra); // fallback to original URI } } } catch { Alert.alert('Photo unavailable', 'The photo picker could not be opened. Please try again.'); } }; const takeGardenPhoto = async (onPhoto: (uri: string, extra?: Partial>) => void) => { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) { Alert.alert('Camera access needed', 'Allow camera access to take a photo for this item.'); return; } const response = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.82, }); if (!response.canceled && response.assets[0]?.uri) { // Capture compass heading (always, no permission) + altitude (best-effort) at capture-time. const sensorExtra = await makeCameraContextExtra(); // Persist photo to permanent storage before returning URI try { const record = await persistPhoto(response.assets[0].uri, { photoType: 'memory' }); onPhoto(record.activeUri, sensorExtra); } catch { onPhoto(response.assets[0].uri, sensorExtra); // fallback to original URI } } } catch { Alert.alert('Camera unavailable', 'The camera could not be opened. Please try again.'); } }; const pickGardenPhoto = (onPhoto: (uri: string, extra?: Partial>) => void) => { pendingPhotoCallbackRef.current = onPhoto as (uri: string) => void; setShowPhotoPicker(true); }; // Photo guidance — show a sheet on demand ("?" trigger), or auto-show the // first time a user reaches a given photo type. const showPhotoGuidance = (type: PhotoGuidanceType) => { photoGuidanceSeenRef.current[type] = true; setPhotoGuidanceType(type); }; // When it's the first time, show guidance and DEFER the photo picker until // the user dismisses the sheet — otherwise the picker stacks on top of it. const pendingPhotoPickerAfterGuidanceRef = useRef<((uri: string, extra?: Partial>) => void) | null>(null); const openPhotoPickerWithGuidance = (type: PhotoGuidanceType, onPhoto: (uri: string, extra?: Partial>) => void) => { if (!photoGuidanceSeenRef.current[type]) { photoGuidanceSeenRef.current[type] = true; pendingPhotoPickerAfterGuidanceRef.current = onPhoto; setPhotoGuidanceType(type); } else { pickGardenPhoto(onPhoto); } }; const closePhotoGuidance = () => { setPhotoGuidanceType(null); const pending = pendingPhotoPickerAfterGuidanceRef.current; if (pending) { pendingPhotoPickerAfterGuidanceRef.current = null; pickGardenPhoto(pending); } }; const confirmDeletePlant = (plant: SavedPlantProfile) => { // SHARED CONTAINER (Bekky, 2026-09-04): if this plant has a roommate, ask // what happened to the roommate before deleting (3 exhaustive outcomes). if (plant.sharedContainerId) { const roommate = savedPlants.find(p => p.id !== plant.id && p.sharedContainerId === plant.sharedContainerId); if (roommate) { setDeleteRoommateTarget({ plantId: plant.id, roommateId: roommate.id }); return; } } setPendingDelete({ key: savedGardenKey(plant.id), label: getSavedPlantDisplayName(plant) }); }; const confirmDeleteSamplePlant = (plant: (typeof gardenPlants)[number]) => { setPendingDelete({ key: sampleGardenKey(plant.name), label: getSamplePlantDisplayName(plant) }); }; const deleteGardenItemNow = (key: string) => { if (key.startsWith(SAVED_PREFIX)) { onDeletePlant(key.slice(SAVED_PREFIX.length)); setPlantSetups(current => current.filter(setup => setup.plantId !== key)); } else if (key.startsWith('sample:')) { const plantName = key.slice(7); setSamplePlants(current => current.filter(plant => plant.name !== plantName)); } setGardenOrderKeys(current => current.filter(itemKey => itemKey !== key)); setSelectedPlant(current => { if (current?.kind === 'saved' && key === savedGardenKey(current.plant.id)) return null; if (current?.kind === 'sample' && key === sampleGardenKey(current.plant.name)) return null; return current; }); }; const cancelPendingDelete = () => { setPendingDelete(null); }; const confirmPendingDelete = () => { if (!pendingDelete) return; deleteGardenItemNow(pendingDelete.key); setPendingDelete(null); haptic.warning(); }; // --- Multi-delete mode (Bekky 2026-08-21) --- const enterMultiDelete = (item: GardenItem) => { // A long-press on a tile enters multi-delete mode and selects that tile. setMultiDeleteKeys(new Set([getPlantItemId(item)])); haptic.light(); }; const toggleMultiDeleteKey = (key: string) => { setMultiDeleteKeys(current => { if (!current) return current; const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); haptic.light(); }; const exitMultiDelete = () => setMultiDeleteKeys(null); const confirmMultiDelete = () => { if (!multiDeleteKeys) return; multiDeleteKeys.forEach(key => deleteGardenItemNow(key)); setMultiDeleteKeys(null); haptic.warning(); }; // Wishlist multi-delete (Bekky, 2026-08-25): long-press enters + selects, // tap toggles, Delete confirms. Mirrors the garden multi-delete. const enterWishlistDelete = (item: WishlistItem) => { setWishlistDeleteKeys(new Set([item.id])); haptic.light(); }; const toggleWishlistDeleteKey = (id: string) => { setWishlistDeleteKeys(current => { if (!current) return current; const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); haptic.light(); }; const exitWishlistDelete = () => setWishlistDeleteKeys(null); const confirmWishlistDelete = () => { if (!wishlistDeleteKeys) return; wishlistDeleteKeys.forEach(id => onRemoveWishlistItem(id)); setWishlistDeleteKeys(null); haptic.warning(); }; // Field album multi-delete (Bekky, 2026-09-03) — mirrors the wishlist pattern. const enterFieldDelete = (album: FieldAlbum) => { setFieldDeleteKeys(new Set([album.id])); haptic.light(); }; const toggleFieldDeleteKey = (id: string) => { setFieldDeleteKeys(current => { if (!current) return current; const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); haptic.light(); }; const exitFieldDelete = () => setFieldDeleteKeys(null); const confirmFieldDelete = () => { if (!fieldDeleteKeys) return; fieldDeleteKeys.forEach(id => onRemoveFieldAlbum(id)); setFieldDeleteKeys(null); haptic.warning(); }; const getDetailHighlightKey = (target: DetailHighlightTarget) => `${target.form}:${target.field}`; const clearDetailTargetHighlight = () => { if (detailHighlightTimerRef.current) { clearTimeout(detailHighlightTimerRef.current); detailHighlightTimerRef.current = null; } pendingDetailHighlightTargetRef.current = null; setDetailHighlightKey(null); }; const showDetailTargetHighlight = (target: DetailHighlightTarget) => { if (detailHighlightTimerRef.current) { clearTimeout(detailHighlightTimerRef.current); } setDetailHighlightKey(getDetailHighlightKey(target)); detailHighlightTimerRef.current = setTimeout(() => { setDetailHighlightKey(null); detailHighlightTimerRef.current = null; }, 1050); }; const getDetailTargetHighlightStyle = (target: DetailHighlightTarget) => ( detailHighlightKey === getDetailHighlightKey(target) ? s.detailTargetHighlight : null ); useEffect(() => () => { if (detailHighlightTimerRef.current) clearTimeout(detailHighlightTimerRef.current); }, []); const closeGardenForms = () => { setActiveForm(null); setEditingPlantId(null); setEditingSpaceId(null); setEditingSetupPlantId(null); setAssigningSpaceId(null); setAssignPlantKeys([]); setGardenDetail(null); setPlantReturnDetail(null); setPlantFormReturnDetail(null); setGardenFormReturnDetail(null); setResumePlantFormAfterSpace(false); setPlantEventForm(makeEmptyPlantEventForm()); pendingDetailFieldTargetRef.current = null; clearDetailTargetHighlight(); setOpenGardenDropdown(null); // Reset the pager initial-snap guard (L14) so a form-close remount re-snaps // to the current section instead of desyncing the highlighted segment. gardenPagerInitialSnapRef.current = false; }; // Scroll the horizontal pager to a garden section (Bekky, 2026-08-15). const scrollGardenPagerToSection = (section: GardenSection) => { const idx = GARDEN_SECTION_ORDER.indexOf(section); if (idx < 0 || !gardenPagerRef.current) return; const w = gardenPagerWidthRef.current || gardenPagerWidth; // If the pager width hasn't been measured yet (e.g. the pager just remounted // after a case-detail / detail-screen early-return), we can't compute the x // offset. Bail and let the onLayout-driven retry (pendingScrollToSectionRef) // fire once the width is known — otherwise we'd scroll to x:0 (Plants) and // desync the highlighted segment from the visible page (the "fusion" bug). if (!w) { pendingScrollToSectionRef.current = section; return; } gardenPagerScrollingRef.current = true; gardenPagerRef.current.scrollTo({ x: idx * w, animated: false }); // Reset the programmatic-scroll guard shortly after so a subsequent user // swipe isn't misinterpreted. setTimeout(() => { gardenPagerScrollingRef.current = false; }, 300); }; // Keep the horizontal pager in sync with gardenSection when it changes // programmatically (e.g. from forms/back-navigation), not just from taps/swipes. useEffect(() => { if (!gardenPagerScrollingRef.current) { scrollGardenPagerToSection(gardenSection); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [gardenSection]); const scheduleDetailFieldScroll = (target: DetailFormTarget) => { pendingDetailFieldTargetRef.current = target; pendingDetailHighlightTargetRef.current = target; setPendingFormScrollTarget(target.form); }; const scheduleDetailFormScroll = (form: 'plant' | 'setup' | 'space', highlightTarget?: DetailHighlightTarget) => { pendingDetailFieldTargetRef.current = null; pendingDetailHighlightTargetRef.current = highlightTarget || null; setPendingFormScrollTarget(form); }; const getDetailFormY = (form: DetailFormTarget['form']) => { if (form === 'plant') return plantFormYRef.current; if (form === 'setup') return setupFormYRef.current; return spaceFormYRef.current; }; const getDetailFieldY = (target: DetailFormTarget) => { if (target.form === 'plant') return plantFieldYRef.current[target.field]; if (target.form === 'setup') return setupFieldYRef.current[target.field]; return spaceFieldYRef.current[target.field]; }; const tryScrollToDetailField = (form: DetailFormTarget['form']) => { const target = pendingDetailFieldTargetRef.current; if (!target || target.form !== form) return false; const highlightTarget = pendingDetailHighlightTargetRef.current || target; pendingDetailFieldTargetRef.current = null; pendingDetailHighlightTargetRef.current = null; setPendingFormScrollTarget(null); // Anchor to the form's own header (device-adaptive, accurate) rather than // cached Y arithmetic that goes stale if layout shifts. The field highlight // still points at the specific field so the user sees where to go. const headerRef = form === 'plant' ? plantFormHeaderRef : form === 'setup' ? setupFormHeaderRef : spaceFormHeaderRef; scrollToFormHeader(headerRef, 40); setTimeout(() => showDetailTargetHighlight(highlightTarget), 280); return true; }; const recordDetailFieldLayout = (target: DetailFormTarget, y: number) => { if (target.form === 'plant') { plantFieldYRef.current[target.field] = y; } else if (target.form === 'setup') { setupFieldYRef.current[target.field] = y; } else { spaceFieldYRef.current[target.field] = y; } tryScrollToDetailField(target.form); }; const scrollDetailToForm = (target: 'plant' | 'setup' | 'space', y: number) => { if (pendingFormScrollTarget !== target) return; if (target === 'plant' || target === 'setup' || target === 'space') { const didScrollToField = tryScrollToDetailField(target); if (didScrollToField || pendingDetailFieldTargetRef.current?.form === target) return; } setPendingFormScrollTarget(null); const highlightTarget = pendingDetailHighlightTargetRef.current; pendingDetailHighlightTargetRef.current = null; // Anchor to the form's own header by measuring it against the ScrollView at // scroll-time (device-adaptive, accurate — the raw onLayout y is relative to // the parent, not the scroll content, so it lands in the wrong place). const headerRef = target === 'plant' ? plantFormHeaderRef : target === 'setup' ? setupFormHeaderRef : spaceFormHeaderRef; scrollToFormHeader(headerRef, 30); if (highlightTarget) setTimeout(() => showDetailTargetHighlight(highlightTarget), 260); }; const scrollGardenListToForm = (target: 'plant' | 'space', y: number) => { if (pendingFormScrollTarget !== target) return; setPendingFormScrollTarget(null); setTimeout(() => { gardenScrollRef.current?.scrollTo({ y: Math.max(y - 12, 0), animated: true }); }, 40); }; // Header-anchored scroll for the GARDEN list's inline form (gardenScrollRef). // Measures the form header against the garden ScrollView at scroll-time. const scrollToGardenFormHeader = (headerRef: React.RefObject, delay = 90) => { setTimeout(() => { const ref = headerRef.current; if (ref && gardenScrollRef.current) { ref.measureLayout( gardenScrollRef.current as unknown as number, (_x, y) => { gardenScrollRef.current?.scrollTo({ y: Math.max(y - 12, 0), animated: true }); }, () => {}, ); } }, delay); }; const scrollToSection = (sectionRef: React.RefObject, delay = 60) => { setTimeout(() => { const ref = sectionRef.current; if (ref && detailScrollRef.current) { ref.measureLayout( detailScrollRef.current as unknown as number, (_x, y) => { detailScrollRef.current?.scrollTo({ y: Math.max(y - 8, 0), animated: true }); }, () => {}, ); } }, delay); }; // Header-anchored form scroll: measures the form's own header card against the // ScrollView at scroll-time (device-adaptive, never relies on stale cached Y), // then scrolls it just below the top with a small offset. const scrollToFormHeader = (headerRef: React.RefObject, delay = 90) => { setTimeout(() => { const ref = headerRef.current; if (ref && detailScrollRef.current) { ref.measureLayout( detailScrollRef.current as unknown as number, (_x, y) => { detailScrollRef.current?.scrollTo({ y: Math.max(y - 12, 0), animated: true }); }, () => {}, ); } }, delay); }; const scrollToOriginatingSection = () => { if (originatingSection === 'environment') scrollToSection(environmentSectionRef, 100); else if (originatingSection === 'setup') scrollToSection(setupSectionRef, 100); else if (originatingSection === 'memory') scrollToSection(progressSectionRef, 100); else if (originatingSection === 'care') scrollToSection(careGuidanceSectionRef, 100); setOriginatingSection(null); }; const deleteConfirmModal = ( Are you sure you want to delete? {pendingDelete?.label || 'This plant'} will be removed from My Garden. No Yes, Delete ); const multiDeleteConfirmModal = ( setShowMultiDeleteConfirm(false)}> Delete {multiDeleteKeys?.size ?? 0} plant{multiDeleteKeys?.size === 1 ? '' : 's'}? These plants will be removed from My Garden. This can't be undone. setShowMultiDeleteConfirm(false))} accessibilityRole="button" accessibilityLabel="Cancel, keep plants" > Cancel { setShowMultiDeleteConfirm(false); confirmMultiDelete(); }, haptic.warning)} accessibilityRole="button" accessibilityLabel="Delete selected plants" > Delete ); const wishlistDeleteConfirmModal = ( <> setShowWishlistDeleteConfirm(false)}> Remove {wishlistDeleteKeys?.size ?? 0} item{wishlistDeleteKeys?.size === 1 ? '' : 's'} from wishlist? These items will be removed from your wishlist. This can't be undone. setShowWishlistDeleteConfirm(false))} accessibilityRole="button" accessibilityLabel="Cancel, keep items" > Cancel { setShowWishlistDeleteConfirm(false); confirmWishlistDelete(); }, haptic.warning)} accessibilityRole="button" accessibilityLabel="Remove selected items" > Remove setShowFieldDeleteConfirm(false)}> Remove {fieldDeleteKeys?.size ?? 0} album{fieldDeleteKeys?.size === 1 ? '' : 's'} from your field diary? These albums and their finds will be removed. This can't be undone. setShowFieldDeleteConfirm(false))} accessibilityRole="button" accessibilityLabel="Cancel, keep albums" > Cancel { setShowFieldDeleteConfirm(false); confirmFieldDelete(); }, haptic.warning)} accessibilityRole="button" accessibilityLabel="Remove selected albums" > Remove ); // Photo delete confirm (placement / setup / environment) — shared so it renders // in EVERY branch that can trigger setPendingPhotoDelete (space, kit, plant-detail, // garden). Without this, haptic fires but no dialog appears (modal-not-in-branch bug). const photoDeleteConfirmModal = ( setPendingPhotoDelete(null)}> Delete this photo? Are you sure you want to delete this photo? This can't be undone. setPendingPhotoDelete(null))} > Cancel { const target = pendingPhotoDelete; if (target) target.confirm(); setPendingPhotoDelete(null); })} > Delete ); const spacePickerModal = ( setShowSpacePicker(false)} statusBarTranslucent navigationBarTranslucent hardwareAccelerated> setShowSpacePicker(false)} /> Move to Space Choose where this plant lives. {rooms.map(room => { const isSelected = pendingSpaceId === room.id; return ( setPendingSpaceId(room.id))} > {isSelected ? `✓ ${room.name}` : room.name} ); })} { setShowSpacePicker(false); scrollToOriginatingSection(); })} > Cancel { if (selectedPlant?.kind !== 'saved') return; const movingPlant = selectedPlant.plant; onUpdatePlant(movingPlant.id, { spaceId: pendingSpaceId, assignedSpaceId: pendingSpaceId || undefined, spaceName: undefined, space: undefined, location: undefined, }); // SHARED CONTAINER MOVE-TOGETHER (Bekky, 2026-09-04): if this // plant shares a pot, its roommates MUST move with it (a shared // container can't straddle two spaces). If the user explicitly // removed roommates first, there are none left to move. if (movingPlant.sharedContainerId) { const roommatesToMove = savedPlants.filter( p => p.id !== movingPlant.id && p.sharedContainerId === movingPlant.sharedContainerId ); roommatesToMove.forEach(rm => { onUpdatePlant(rm.id, { spaceId: pendingSpaceId, assignedSpaceId: pendingSpaceId || undefined, spaceName: undefined, space: undefined, location: undefined, }); }); } setShowSpacePicker(false); scrollToOriginatingSection(); // AI no longer fires on space change — the single "Save and // refresh Pixie Intelligence" button handles text enrichment + // photo analysis together (Bekky, 2026-08-19). Flash that // button so the user notices it's the next step. flashRefreshButton(); }, haptic.success)} > Save Space ); const eventDeleteConfirmModal = ( setPendingEventDelete(null)}> Delete this memory? This will remove it from this plant's history. setPendingEventDelete(null))}> Cancel pendingEventDelete ? deletePlantEvent(pendingEventDelete.id) : undefined, haptic.warning)} > Delete ); const openPlantForm = (plant?: SavedPlantProfile, options?: { preselectedSpaceId?: string | null; returnDetail?: GardenDetail; scrollToForm?: boolean; targetField?: PlantFormTarget }) => { const validPreselectedSpaceId = options?.preselectedSpaceId && rooms.some(room => room.id === options.preselectedSpaceId) ? options.preselectedSpaceId : null; setEditingPlantId(plant?.id || null); setPlantForm(plant ? { name: getSavedPlantDisplayName(plant), scientificName: plant.scientificName && cleanScientificName(plant.scientificName) !== getSavedPlantDisplayName(plant) ? cleanScientificName(plant.scientificName) : '', plantType: plant.plantType, growthStage: plant.growthStage || 'unsure', timeOwned: plant.timeOwned || 'unsure', photoUri: plant.photoUri || undefined, spaceId: resolvePlantSpaceId(plant) || null, notes: plant.manualNotes || plant.notes || '', } : { ...makeEmptyPlantForm(), spaceId: validPreselectedSpaceId, }); setPlantFormReturnDetail(options?.returnDetail || null); setActiveForm('plant'); if (options?.returnDetail?.kind === 'space') { setGardenSection('spaces'); } else if (!plant) { setGardenSection('plants'); } if (!options?.returnDetail) setGardenDetail(null); if (options?.targetField) scheduleDetailFieldScroll({ form: 'plant', field: options.targetField }); if (options?.scrollToForm) setPendingFormScrollTarget('plant'); }; const openAssignPlantsForSpace = (spaceId: string) => { setAssigningSpaceId(spaceId); setAssignPlantKeys(getAssignedGardenItemsForSpace(spaceId).map(item => item.key)); setActiveForm('assignPlants'); }; const toggleAssignPlant = (key: string) => { setAssignPlantKeys(current => current.includes(key) ? current.filter(itemKey => itemKey !== key) : [...current, key]); }; const cancelAssignPlants = () => { setActiveForm(null); setAssigningSpaceId(null); setAssignPlantKeys([]); }; const closeSpaceDetail = () => { // Garden pager remounts when this detail early-return unmounts it — allow a // fresh snap to the current section on next mount. gardenPagerInitialSnapRef.current = false; if (activeForm === 'assignPlants' || activeForm === 'plant' || activeForm === 'space') { setActiveForm(null); setEditingPlantId(null); setEditingSpaceId(null); setAssigningSpaceId(null); setAssignPlantKeys([]); setPlantFormReturnDetail(null); setGardenFormReturnDetail(null); // Clear the space→plant return target (L15) — otherwise closing the form // leaves a stale ref that navigates back to the wrong plant on next back. spaceReturnPlantRef.current = null; return; } // If we navigated here from a plant detail, go back to the plant const returnPlant = spaceReturnPlantRef.current; if (returnPlant) { spaceReturnPlantRef.current = null; setGardenDetail(null); setSelectedPlant(returnPlant); return; } setActiveForm(null); setEditingPlantId(null); setEditingSpaceId(null); setAssigningSpaceId(null); setAssignPlantKeys([]); setPlantFormReturnDetail(null); setGardenFormReturnDetail(null); setGardenSection('spaces'); setGardenDetail(null); }; const saveAssignedPlantsForSpace = (spaceId: string) => { if (savingGardenForm) return; const roomExists = rooms.some(room => room.id === spaceId); if (!roomExists) { cancelAssignPlants(); return; } setSavingGardenForm(true); const selectedKeys = new Set(assignPlantKeys); savedPlants.forEach(plant => { const key = savedGardenKey(plant.id); const currentSpaceId = plant.spaceId || null; const nextSpaceId = selectedKeys.has(key) ? spaceId : currentSpaceId === spaceId ? null : currentSpaceId; if (currentSpaceId !== nextSpaceId) { onUpdatePlant(plant.id, { spaceId: nextSpaceId, assignedSpaceId: nextSpaceId || undefined, spaceName: undefined, space: undefined, location: undefined, locationContext: nextSpaceId ? 'home' : 'unsure', }); } }); setSamplePlants(current => current.map(plant => { const key = sampleGardenKey(plant.name); const currentSpaceId = plant.spaceId || null; const nextSpaceId = selectedKeys.has(key) ? spaceId : currentSpaceId === spaceId ? null : currentSpaceId; return currentSpaceId === nextSpaceId ? plant : { ...plant, spaceId: nextSpaceId }; })); setTimeout(() => { setSavingGardenForm(false); cancelAssignPlants(); }, 180); }; const savePlantForm = () => { const cleanName = plantForm.name.trim(); if (!cleanName || savingGardenForm) return; setSavingGardenForm(true); const selectedSpaceId = plantForm.spaceId && rooms.some(room => room.id === plantForm.spaceId) ? plantForm.spaceId : null; const input = { name: cleanName, scientificName: plantForm.scientificName.trim() || undefined, plantType: plantForm.plantType, growthStage: plantForm.growthStage, timeOwned: plantForm.timeOwned, photoUri: plantForm.photoUri, spaceId: selectedSpaceId, notes: plantForm.notes.trim() || undefined, manualNotes: plantForm.notes.trim() || undefined, }; if (editingPlantId) { const patch: Partial = { commonName: input.name, name: input.name, scientificName: input.scientificName || input.name, plantType: input.plantType, category: input.plantType, growthStage: input.growthStage, timeOwned: input.timeOwned, photoUri: input.photoUri || '', image: input.photoUri, spaceId: input.spaceId, assignedSpaceId: input.spaceId || undefined, spaceName: undefined, space: undefined, locationContext: input.spaceId ? 'home' : 'unsure', notes: input.notes, manualNotes: input.manualNotes, }; onUpdatePlant(editingPlantId, patch); } else { const profile = createManualPlantProfile(input); onSavePlant(profile); setSelectedPlant({ kind: 'saved', plant: profile }); setGardenDetail(null); setPlantReturnDetail(null); setGardenSection('plants'); // First-fetch guarantee for a newly added manual plant: fetch // bio/wiki/care/propagation in the background with retry+backoff so the // plant ALWAYS gets its full detail page, even if the first AI call blips. // (ensurePlantEnriched retries up to 3 times; a fresh plant has no data // yet so it never short-circuits.) Fire-and-forget, never blocks the UI. const enrichName = profile.commonName || profile.name; const enrichSci = profile.scientificName; if (enrichName) { // Build the care context packet so the first-fetch is context-aware // (H12) — otherwise the manual plant gets generic care that ignores its // space/setup. Mirrors the scan path's context-aware enrichment. const enrichRoom = profile.spaceId ? rooms.find(r => r.id === profile.spaceId) : undefined; const enrichSetup = setupByPlantId.get(savedGardenKey(profile.id)); const enrichPacket = buildCareContextPacket({ plant: profile, room: enrichRoom, setup: enrichSetup, weather: weatherSnapshot ?? undefined }); import('../services/plantEnrichment').then(mod => { mod.ensurePlantEnriched(profile, { organicFirst: carePreferences?.organicFirst, location: approximateLocationContext || null, contextPacket: renderCareContextPacket(enrichPacket), }).then(result => { if (result) { onUpdatePlant(profile.id, { enrichmentData: result.enrichmentData, propagationInfo: result.propagationInfo, // NOTE (Bekky, 2026-08-20): care schedule + guidance are NOT part // of the basics call — generated separately via the "Generate // Custom Care Guidance" button (generateCareGuidance). }); } }).catch(() => { // Best-effort — silently ignore enrichment failures }); }).catch(() => { // Best-effort — silently ignore dynamic import failures }); } } setTimeout(() => { setSavingGardenForm(false); setActiveForm(null); setEditingPlantId(null); if (plantFormReturnDetail && editingPlantId) { setGardenSection('spaces'); setGardenDetail( plantFormReturnDetail.kind === 'space' && !rooms.some(room => room.id === plantFormReturnDetail.id) ? null : plantFormReturnDetail, ); setPlantFormReturnDetail(null); } }, 180); }; const openSpaceForm = (room?: RoomProfile, options?: { returnDetail?: GardenDetail; scrollToForm?: boolean; targetField?: SpaceFormTarget }) => { setEditingSpaceId(room?.id || null); setSpaceNameTouched(false); setSpaceForm(room ? { name: room.name, type: room.type, spaceType: room.spaceType, lightProfile: room.lightProfile, humidityProfile: room.humidityProfile, windowDirections: room.windowDirections || [], lightLevel: room.lightLevel, outdoorSunTimings: room.outdoorSunTimings || [], outdoorLightQuality: room.outdoorLightQuality || 'unsure', usesAmbientArtificialLight: Boolean(room.usesAmbientArtificialLight || (room as RoomProfile & { usesArtificialLight?: boolean }).usesArtificialLight), ambientArtificialLightTypes: room.ambientArtificialLightTypes || (room as RoomProfile & { artificialLightTypes?: ArtificialLightType[] }).artificialLightTypes || [], ambientArtificialLightHoursPerDay: room.ambientArtificialLightHoursPerDay ?? (room as RoomProfile & { artificialLightHoursPerDay?: number }).artificialLightHoursPerDay, humidityEstimate: room.humidityEstimate, temperatureEstimate: room.temperatureEstimate, climateDevices: room.climateDevices, windowOpenFrequency: room.windowOpenFrequency, cohortsEnabled: room.cohortsEnabled !== false, // default ON for legacy rooms (absent = grouped) notes: cleanSpaceNotesForDisplay(room.notes), photoUri: normalizeContextPhotos(room.environmentPhotos, [room.environmentPhotoUri, room.photoUri])[0]?.uri, environmentPhotoUri: normalizeContextPhotos(room.environmentPhotos, [room.environmentPhotoUri, room.photoUri])[0]?.uri, environmentPhotos: normalizeContextPhotos(room.environmentPhotos, [room.environmentPhotoUri, room.photoUri]), } : makeEmptySpaceForm()); setGardenFormReturnDetail(options?.returnDetail || null); setActiveForm('space'); setGardenSection('spaces'); if (!options?.returnDetail) setGardenDetail(null); if (options?.targetField) scheduleDetailFieldScroll({ form: 'space', field: options.targetField }); if (options?.scrollToForm) setPendingFormScrollTarget('space'); }; // Pixie Intelligence: recover stuck analyses on app restart useEffect(() => { let cancelled = false; (async () => { try { const recovered = await recoverStuckAnalyses(); if (cancelled || !recovered.length) return; showIntelligenceMessage(`Resuming photo analysis for ${recovered.length} photo${recovered.length > 1 ? 's' : ''}...`, 'analyzing'); for (const entry of recovered) { if (cancelled) break; try { await analyzeAndStorePhoto({ photoUri: entry.photoUri, photoType: entry.photoType as 'environment' | 'setup' | 'progress', plantId: entry.plantId, }); } catch { /* individual retry failed, skip */ } } if (!cancelled) { showIntelligenceMessage('Photo analysis recovery complete.', 'complete'); } } catch { /* recovery failed silently */ } })(); return () => { cancelled = true; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Pixie Intelligence: fire-and-forget photo analysis after save const showIntelligenceMessage = (message: string, status: AnalysisStatus, photoType?: 'environment' | 'placement' | 'setup' | 'progress') => { setIntelligenceMessage(message); setIntelligenceStatus(status); if (photoType) { setIntelligencePhotoType(photoType); setSectionMessages(current => ({ ...current, [photoType]: { message, status } })); } if (intelligenceTimerRef.current) clearTimeout(intelligenceTimerRef.current); if (status === 'complete') { intelligenceTimerRef.current = setTimeout(() => { setIntelligenceMessage(null); setIntelligenceStatus(null); setIntelligencePhotoType(null); setSectionMessages({}); }, 8000); } // 'analyzing' and 'failed' stay visible until replaced or explicitly cleared }; const triggerPhotoIntelligence = async ( photos: GardenContextPhoto[], photoType: 'environment' | 'placement' | 'setup' | 'progress', plantId: string, plantCommonName?: string, plantScientificName?: string, learningMessage?: string, learnedMessage?: string, ) => { if (!photos.length) return; // Section-aware message (Bekky, 2026-08-19): when called from the refresh // flow we pass "Pixie is learning from your setup and photos..." so the // photo analysis doesn't overwrite the per-section status with a generic // "learning more about this plant." Standalone calls (progress photos) // keep the generic message. showIntelligenceMessage(learningMessage || 'Pixie is learning more about this plant.', 'analyzing', photoType); setIntelligenceFailedInputs([]); const inputs: PhotoAnalysisInput[] = photos.map(photo => ({ photoUri: photo.uri, photoType, plantId, plantCommonName, plantScientificName, // Heading/altitude are captured at CAPTURE-TIME on live camera shots and // stored on the photo. Gallery-imported photos carry none (data would be // stale). We do NOT re-read GPS here at analysis time — that would capture // where the user is now, not where the plant was. heading: photo.source === 'camera' ? photo.heading : undefined, altitude: photo.source === 'camera' ? photo.altitude : undefined, })); const MAX_RETRIES = 3; const RETRY_DELAY_MS = 5 * 60 * 1000; // 5 minutes const attemptAnalysis = async (retryAttempt: number) => { const failedInputs: PhotoAnalysisInput[] = []; let completed = 0; const total = inputs.length; // Serialize per-photo analysis (H6): each analyzeAndStorePhoto does a // read-modify-write on the same intelligence profile. Running them // concurrently caused last-writer-wins — N−1 results were silently lost. // Awaiting each in turn keeps every write atomic. for (const input of inputs) { try { const result = await analyzeAndStorePhoto(input); completed++; if (result.analysisStatus === 'failed') { failedInputs.push(input); } } catch { completed++; failedInputs.push(input); } } if (failedInputs.length > 0) { if (retryAttempt < MAX_RETRIES) { showIntelligenceMessage(`Pixie is still working on it... (attempt ${retryAttempt + 1} of ${MAX_RETRIES})`, 'analyzing', photoType); if (intelligenceRetryRef.current) clearTimeout(intelligenceRetryRef.current); intelligenceRetryRef.current = setTimeout(() => { void attemptAnalysis(retryAttempt + 1); }, RETRY_DELAY_MS); } else { // All retries exhausted setIntelligenceFailedInputs([...failedInputs]); showIntelligenceMessage("Pixie couldn't analyze this photo after several tries.", 'failed', photoType); try { if (typeof __DEV__ !== 'undefined' && __DEV__) { console.warn('[PixieIntelligence] All retries exhausted for', photoType, 'photos. Failed inputs:', failedInputs.length); } } catch { /* __DEV__ not available */ } } } else { showIntelligenceMessage(learnedMessage || 'Pixie learned from this photo.', 'complete', photoType); } }; void attemptAnalysis(1); // Dev-only: validate context packet generation try { if (typeof __DEV__ !== 'undefined' && __DEV__) { buildInvestigationContextPacket(plantId).then(packet => { }).catch(() => {}); } } catch { /* __DEV__ not available */ } }; const saveSpaceForm = () => { const cleanName = spaceForm.name.trim(); if (!cleanName || savingGardenForm) { if (!cleanName) setSpaceSaveAttempted(true); return; } setSavingGardenForm(true); try { const environmentPhotos = normalizeContextPhotos(spaceForm.environmentPhotos, [spaceForm.environmentPhotoUri, spaceForm.photoUri]); const environmentPhotoUri = environmentPhotos[0]?.uri; const input = { ...spaceForm, name: cleanName, // Window directions are only meaningful for INDOOR spaces — outdoor spaces // don't have a window to place a plant near (Bekky, 2026-08-23). This also // stops the placement proximity rows from showing a "Window" for outdoor. windowDirections: isIndoorSpaceType(spaceForm.spaceType) ? (spaceForm.windowDirections || []) : (['none'] as WindowDirection[]), lightLevel: isOutdoorSpaceType(spaceForm.spaceType) ? spaceForm.lightLevel : lightLevelFromProfile(spaceForm.lightProfile), outdoorSunTimings: isOutdoorSpaceType(spaceForm.spaceType) ? spaceForm.outdoorSunTimings || [] : [], outdoorLightQuality: isOutdoorSpaceType(spaceForm.spaceType) ? spaceForm.outdoorLightQuality || 'unsure' : 'unsure', usesAmbientArtificialLight: Boolean(spaceForm.usesAmbientArtificialLight), ambientArtificialLightTypes: spaceForm.usesAmbientArtificialLight ? spaceForm.ambientArtificialLightTypes || [] : [], ambientArtificialLightHoursPerDay: spaceForm.usesAmbientArtificialLight ? spaceForm.ambientArtificialLightHoursPerDay : undefined, humidityEstimate: humidityEstimateFromProfile(spaceForm.humidityProfile), // Climate & air (Bekky, 2026-08-21): only meaningful for indoor spaces. climateDevices: isIndoorSpaceType(spaceForm.spaceType) ? spaceForm.climateDevices : undefined, windowOpenFrequency: isIndoorSpaceType(spaceForm.spaceType) ? spaceForm.windowOpenFrequency : undefined, // Elevation applies to ALL spaces (Bekky, 2026-08-23) — higher floors get // more sun/wind. Exposure (roof/walls/facing) only for OUTDOOR spaces. elevation: spaceForm.elevation || undefined, exposure: isOutdoorSpaceType(spaceForm.spaceType) ? spaceForm.exposure || undefined : undefined, // Intelligent groupings toggle (Bekky, 2026-08-30): persists the user's // per-space opt-out. Stored as undefined when ON (legacy-compatible: // "absent = grouped"), explicit false only when opted out. cohortsEnabled: spaceForm.cohortsEnabled === false ? false : undefined, notes: spaceForm.notes?.trim() || undefined, photoUri: environmentPhotoUri, environmentPhotoUri, environmentPhotos, }; if (editingSpaceId) { setRooms(current => current.map(room => room.id === editingSpaceId ? updateRoomProfile(room, input) : room)); } else { const room = createRoomProfile(input); setRooms(current => [room, ...current]); } setSavingGardenForm(false); setActiveForm(null); setEditingSpaceId(null); // No AI fires on environment save (Bekky, 2026-08-19) and no refresh // button flash either (Bekky, 2026-09-08): the space save is a plain // "Save" — background auto-triggers (auto-care 5-min scan + startup // enrichment recovery) handle enrichment on their own cadence. if (gardenFormReturnDetail) { setGardenDetail( gardenFormReturnDetail.kind === 'space' && !rooms.some(room => room.id === gardenFormReturnDetail.id) ? null : gardenFormReturnDetail, ); setGardenFormReturnDetail(null); } } catch { setSavingGardenForm(false); } }; const deleteSpace = (roomId: string) => { setRooms(current => current.filter(room => room.id !== roomId)); setPlantSetups(current => current.map(setup => setup.roomId === roomId ? { ...setup, roomId: undefined, updatedAt: new Date().toISOString() } : setup)); savedPlants .filter(plant => resolvePlantSpaceId(plant) === roomId) .forEach(plant => onUpdatePlant(plant.id, { spaceId: null, assignedSpaceId: undefined, spaceName: undefined, space: undefined, location: undefined, locationContext: 'unsure' })); }; // Fetch AI repotting guidance on demand (Bekky, 2026-08-15). Called when the // user taps "Get repotting guidance" in the RepotModal. Non-fatal. const requestRepotGuidance = () => { if (selectedPlant?.kind !== 'saved') return; const plant = selectedPlant.plant; setRepotGuidanceRequested(true); setRepotLoading(true); (async () => { try { const room = plant.spaceId ? rooms.find(r => r.id === plant.spaceId) : undefined; const setup = setupByPlantId.get(savedGardenKey(plant.id)); const packet = buildCareContextPacket({ plant, room, setup, weather: weatherSnapshot ?? undefined }); let memoryBlock: string | undefined; try { const memory = await buildPlantMemoryContext(plant.id); memoryBlock = renderMemoryContextBlock(memory) || undefined; } catch { /* non-fatal */ } const guidance = await getRepotGuidance({ plantName: getSavedPlantDisplayName(plant), scientificName: plant.scientificName, contextPacket: packet, plantMemory: memoryBlock, }); setRepotGuidance(guidance); } catch { /* non-fatal */ } setRepotLoading(false); })(); }; const openSetupForm = (plantId: string, targetField?: SetupFormTarget | 'section') => { const setup = setupByPlantId.get(plantId); const savedPlant = plantId.startsWith(SAVED_PREFIX) ? savedPlantById.get(plantId.slice(SAVED_PREFIX.length)) : undefined; const samplePlant = plantId.startsWith('sample:') ? samplePlantByName.get(plantId.slice(7)) : undefined; const assignedSpaceId = savedPlant ? resolvePlantSpaceId(savedPlant) || undefined : samplePlant?.spaceId || undefined; setEditingSetupPlantId(plantId); setFertilizerApplyGroup(null); const existingSetupPhotos = setup ? normalizeContextPhotos(setup.setupPhotos, [setup.setupPhotoUri, setup.photoUris]) : []; setSetupForm(setup ? { roomId: assignedSpaceId, distanceFromWindow: setup.distanceFromWindow, // Legacy setups (no plantedIn field yet): derive it from the saved potType // so their pot questions still show. A truly-unsure setup stays gated. plantedIn: setup.plantedIn ?? (setup.potType === 'ground' || setup.potType === 'garden_bed' ? 'ground' : setup.potType === 'raised_bed' ? 'raised_bed' : (setup.potType && setup.potType !== 'unsure') ? 'pot' : undefined), inGroundKind: setup.inGroundKind, hydroSetup: setup.hydroSetup, potType: setup.potType, potSize: setup.potSize, drainage: setup.drainage, mediumType: setup.mediumType, mediumTypes: setup.mediumTypes || (setup.mediumType && setup.mediumType !== 'unsure' ? [setup.mediumType] : []), customMedium: setup.customMedium, customMediumComponents: setup.customMediumComponents || [], topDressing: setup.topDressing || [], customTopDressing: setup.customTopDressing, wateringMethod: setup.wateringMethod, wateringMethods: setup.wateringMethods || (setup.wateringMethod && setup.wateringMethod !== 'unsure' ? [setup.wateringMethod] : []), wateringStyle: setup.wateringStyle, wateringAutomated: setup.wateringAutomated, customWatering: setup.customWatering, usesDedicatedArtificialLight: setup.usesDedicatedArtificialLight === undefined ? undefined : Boolean(setup.usesDedicatedArtificialLight), dedicatedArtificialLightTypes: setup.dedicatedArtificialLightTypes || [], dedicatedArtificialLightHoursPerDay: setup.dedicatedArtificialLightHoursPerDay, dedicatedArtificialLightDistance: setup.dedicatedArtificialLightDistance, notes: setup.notes, photoUris: contextPhotoUris(existingSetupPhotos), setupPhotoUri: existingSetupPhotos[0]?.uri, setupPhotos: existingSetupPhotos, } : { ...makeEmptySetupForm(), roomId: assignedSpaceId, }); setActiveForm('setup'); if (targetField === 'section') { scheduleDetailFormScroll('setup', { form: 'setup', field: 'section' }); } else if (targetField) { scheduleDetailFieldScroll({ form: 'setup', field: targetField }); } else { setPendingFormScrollTarget('setup'); } }; const saveSetupForm = () => { if (!editingSetupPlantId || savingGardenForm) return; setSavingGardenForm(true); const existing = setupByPlantId.get(editingSetupPlantId); const savedPlantForSetup = editingSetupPlantId.startsWith(SAVED_PREFIX) ? savedPlantById.get(editingSetupPlantId.slice(SAVED_PREFIX.length)) : undefined; const samplePlantForSetup = editingSetupPlantId.startsWith('sample:') ? samplePlantByName.get(editingSetupPlantId.slice(7)) : undefined; const selectedSpaceId = savedPlantForSetup ? resolvePlantSpaceId(savedPlantForSetup) || null : samplePlantForSetup?.spaceId || null; const selectedSpace = rooms.find(room => room.id === selectedSpaceId) || null; const selectedSpaceIsOutdoor = isOutdoorSpaceType(selectedSpace?.spaceType); const allowedPotOptions = getPotTypeOptionsForSpace(selectedSpace?.spaceType); const normalizedPotType = allowedPotOptions.some(option => option.value === setupForm.potType) ? setupForm.potType : 'unsure'; const selectedPotIsInGround = isInGroundPotType(normalizedPotType); const setupPhotos = normalizeContextPhotos(setupForm.setupPhotos, [setupForm.setupPhotoUri, setupForm.photoUris]); const setupPhotoUris = contextPhotoUris(setupPhotos); const input = { ...setupForm, plantId: editingSetupPlantId, roomId: selectedSpaceId || undefined, distanceFromWindow: selectedSpaceIsOutdoor ? 'not_applicable' as const : setupForm.distanceFromWindow === 'not_applicable' ? 'unsure' as const : setupForm.distanceFromWindow, plantedIn: setupForm.plantedIn, inGroundKind: setupForm.inGroundKind, hydroSetup: setupForm.hydroSetup === 'unsure' ? undefined : setupForm.hydroSetup, potType: normalizedPotType, potSize: selectedPotIsInGround ? 'not_applicable' as const : setupForm.potSize === 'not_applicable' ? 'unsure' as const : setupForm.potSize, drainage: selectedPotIsInGround ? 'not_applicable' as const : setupForm.drainage === 'not_applicable' ? 'unsure' as const : setupForm.drainage, mediumType: setupForm.mediumTypes?.[0] as PlantSetupProfile['mediumType'] || 'unsure', mediumTypes: setupForm.mediumTypes || [], customMedium: setupForm.customMedium?.trim() || undefined, customMediumComponents: (setupForm.customMediumComponents || []).filter(v => v !== 'unsure'), topDressing: (setupForm.topDressing || []).filter(v => v !== 'none' && v !== 'unsure'), customTopDressing: setupForm.customTopDressing?.trim() || undefined, wateringMethod: setupForm.wateringMethods?.[0] as PlantSetupProfile['wateringMethod'] || 'unsure', wateringMethods: (setupForm.wateringMethods || []).filter(v => v !== 'unsure'), wateringStyle: setupForm.wateringStyle === 'unsure' ? undefined : setupForm.wateringStyle, wateringAutomated: setupForm.wateringAutomated, customWatering: setupForm.customWatering?.trim() || undefined, notes: setupForm.notes?.trim() || undefined, setupPhotoUri: setupPhotos[0]?.uri, setupPhotos, photoUris: setupPhotoUris, usesDedicatedArtificialLight: Boolean(setupForm.usesDedicatedArtificialLight), dedicatedArtificialLightTypes: setupForm.usesDedicatedArtificialLight ? setupForm.dedicatedArtificialLightTypes || [] : [], dedicatedArtificialLightHoursPerDay: setupForm.usesDedicatedArtificialLight ? setupForm.dedicatedArtificialLightHoursPerDay : undefined, dedicatedArtificialLightDistance: setupForm.usesDedicatedArtificialLight ? setupForm.dedicatedArtificialLightDistance?.trim() || undefined : undefined, }; if (existing) { setPlantSetups(current => current.map(setup => setup.id === existing.id ? updatePlantSetupProfile(setup, input) : setup)); } else { setPlantSetups(current => [createPlantSetupProfile(input), ...current]); } if (savedPlantForSetup) { onUpdatePlant(savedPlantForSetup.id, { setupCompleted: true }); } // FERTILIZER GROUP OPT-IN (Bekky, 2026-09-02 rework): if the user answered // "Apply to the whole group too?" = Yes on a plant that's in a fertilize // cohort, propagate THIS PLANT's fertilizer to every member. NEVER auto — only // when the user explicitly taps Yes. The plant's own setup was already // written above. const rawPlantIdForFert = (editingSetupPlantId?.startsWith(SAVED_PREFIX) ? editingSetupPlantId.slice(SAVED_PREFIX.length) : null); const plantFertValue = setupForm.fertilizer?.trim(); if (fertilizerApplyGroup === true && rawPlantIdForFert && plantFertValue) { rooms.forEach(room => { (room.cohorts?.fertilize || []).forEach(coh => { if (coh.plantIds.includes(rawPlantIdForFert)) { coh.plantIds.forEach(pid => setPlantFertilizer(savedGardenKey(pid), plantFertValue)); } }); }); } setFertilizerApplyGroup(null); // AI is no longer fired on setup save — the single "Save and refresh Pixie // Intelligence" button handles text enrichment + photo analysis together // (Bekky, 2026-08-19). This prevents overlapping AI calls / crosstalk. setTimeout(() => { setSavingGardenForm(false); setActiveForm(null); setEditingSetupPlantId(null); scrollToOriginatingSection(); // Flash the "Save and refresh Pixie Intelligence" button so the user // notices it's the next step (setup data changed — AI is fired there now). if (savedPlantForSetup) flashRefreshButton(); }, 180); }; // SHARED CONTAINER (Bekky, 2026-09-04): confirm the roommate set from the // SharedContainerModal. Writes a sharedContainerId (a UUID) to every selected // plant so they all share the same id (symmetric). Empty selection = standalone // (unlink). Also updates the setup form's sharedContainerId so the chip reflects // the choice, and clears the conflict note (it'll refetch on roommate change). const handleSharedContainerConfirm = (roommateIds: string[]) => { setShowSharedContainerModal(false); const containerId = roommateIds.length > 0 ? (setupForm.sharedContainerId || createGardenId('container')) : undefined; // Update the setup form state so the chip reflects the choice. setSetupForm(current => ({ ...current, sharedContainerId: containerId })); // If editing a saved plant, write the container id to it + all roommates. const rawPlantId = editingSetupPlantId?.startsWith(SAVED_PREFIX) ? editingSetupPlantId.slice(SAVED_PREFIX.length) : editingSetupPlantId; if (containerId && rawPlantId && savedPlantById.get(rawPlantId)) { const allIds = [rawPlantId, ...roommateIds]; // Fire the conflict note ONCE for the whole container (not per-plant) — // one AI call that assesses how the species pair, written to every member. const memberNames = allIds .map(id => savedPlantById.get(id)) .filter(Boolean) .map(p => p!.name || p!.commonName || p!.scientificName || ''); allIds.forEach(pid => { const plant = savedPlantById.get(pid); if (!plant) return; onUpdatePlant(pid, { sharedContainerId: containerId, // Roommate set changed → the conflict note is stale → clear it so it // refetches (Bekky, 2026-09-04). sharedContainerNote: undefined, }); }); // Fire-and-forget the conflict note (never blocks the UI). Best-effort: // if it fails, the container just shows roommates without a note. if (memberNames.length >= 2) { import('../services/plantEnrichment').then(mod => mod.generateSharedContainerNote(memberNames) ).then(note => { if (!note) return; const notePayload = { quality: note.quality, text: note.text, roommateIds: [...roommateIds].sort(), generatedAt: new Date().toISOString(), }; allIds.forEach(pid => { const plant = savedPlantById.get(pid); if (!plant) return; onUpdatePlant(pid, { sharedContainerNote: notePayload }); }); }).catch(() => {}); } // SHARED SETUP + PLACEMENT MERGE (Bekky, 2026-09-04): the moment plants // share a container, their setup + placement + photos are physically the // SAME (one pot, one spot). Whichever member has the RICHEST setup already // done shares it with the ones that have less. Merge: // - setup profile (pot/medium/watering/etc) — richest wins, copied to all // - placement (description + proximity) — richest wins, copied to all // - setup photos + placement photos — unioned (richest set wins) const memberPlants = allIds.map(id => savedPlantById.get(id)).filter(Boolean) as SavedPlantProfile[]; if (memberPlants.length >= 2) { // Pick the member with the most setup fields filled (richest setup). const setupScore = (p: SavedPlantProfile) => { const s = setupByPlantId.get(savedGardenKey(p.id)); if (!s) return 0; let score = 0; if (s.plantedIn) score += 2; if (s.potType) score += 2; if (s.potSize) score += 1; if (s.drainage) score += 1; if (s.mediumTypes?.length) score += 2; if (s.wateringMethods?.length) score += 2; if (s.fertilizer) score += 1; if (s.setupPhotos?.length) score += 2; return score; }; const richest = [...memberPlants].sort((a, b) => setupScore(b) - setupScore(a))[0]; const richestSetup = setupByPlantId.get(savedGardenKey(richest.id)); // Placement richness: description + proximity + photos. const placementScore = (p: SavedPlantProfile) => (p.placementDescription ? 2 : 0) + (p.placementProximity && Object.keys(p.placementProximity).length ? 2 : 0) + (p.placementPhotos?.length ? 2 : 0); const richestPlacement = [...memberPlants].sort((a, b) => placementScore(b) - placementScore(a))[0]; // Copy the richest setup + placement to every member (except the source). memberPlants.forEach(p => { if (p.id === richest.id) return; if (richestSetup) { const existing = setupByPlantId.get(savedGardenKey(p.id)); const mergedSetup = existing ? updatePlantSetupProfile(existing, { plantedIn: richestSetup.plantedIn, potType: richestSetup.potType, potSize: richestSetup.potSize, drainage: richestSetup.drainage, mediumType: richestSetup.mediumType, mediumTypes: richestSetup.mediumTypes, customMedium: richestSetup.customMedium, customMediumComponents: richestSetup.customMediumComponents, topDressing: richestSetup.topDressing, customTopDressing: richestSetup.customTopDressing, wateringMethod: richestSetup.wateringMethod, wateringMethods: richestSetup.wateringMethods, wateringStyle: richestSetup.wateringStyle, wateringAutomated: richestSetup.wateringAutomated, customWatering: richestSetup.customWatering, fertilizer: richestSetup.fertilizer, setupPhotos: richestSetup.setupPhotos, setupPhotoUri: richestSetup.setupPhotoUri, photoUris: richestSetup.photoUris, sharedContainerId: containerId, }) : createPlantSetupProfile({ plantId: savedGardenKey(p.id), roomId: richestSetup.roomId, plantedIn: richestSetup.plantedIn, potType: richestSetup.potType, potSize: richestSetup.potSize, drainage: richestSetup.drainage, mediumType: richestSetup.mediumType, mediumTypes: richestSetup.mediumTypes, customMedium: richestSetup.customMedium, customMediumComponents: richestSetup.customMediumComponents, topDressing: richestSetup.topDressing, customTopDressing: richestSetup.customTopDressing, wateringMethod: richestSetup.wateringMethod, wateringMethods: richestSetup.wateringMethods, wateringStyle: richestSetup.wateringStyle, wateringAutomated: richestSetup.wateringAutomated, customWatering: richestSetup.customWatering, fertilizer: richestSetup.fertilizer, setupPhotos: richestSetup.setupPhotos, setupPhotoUri: richestSetup.setupPhotoUri, photoUris: richestSetup.photoUris, sharedContainerId: containerId, dedicatedArtificialLightTypes: [], }); setPlantSetups(current => current.some(s => s.id === existing?.id) ? current.map(s => s.id === existing?.id ? mergedSetup : s) : [mergedSetup, ...current]); } // Placement merge (description + proximity + photos) — richest wins. if (p.id !== richestPlacement.id) { onUpdatePlant(p.id, { placementDescription: richestPlacement.placementDescription, placementProximity: richestPlacement.placementProximity, placementPhoto: richestPlacement.placementPhoto, placementPhotos: richestPlacement.placementPhotos, }); } }); } } else if (rawPlantId && savedPlantById.get(rawPlantId)) { // Removed all roommates → standalone. Unlink the plant + any old roommates. const oldContainerId = savedPlantById.get(rawPlantId)?.sharedContainerId; onUpdatePlant(rawPlantId, { sharedContainerId: undefined, sharedContainerNote: undefined }); if (oldContainerId) { savedPlants .filter(p => p.sharedContainerId === oldContainerId && p.id !== rawPlantId) .forEach(p => onUpdatePlant(p.id, { sharedContainerId: undefined, sharedContainerNote: undefined })); } } }; // DELETE-ROOMMATE (Bekky, 2026-09-04): when deleting a plant that has a roommate, // the user picks what happened to the roommate (3 exhaustive outcomes). const handleDeleteRoommateOutcome = (outcome: 'delete_both' | 'standalone' | 'repotted') => { const target = deleteRoommateTarget; setDeleteRoommateTarget(null); if (!target) return; const { plantId, roommateId } = target; const roommate = savedPlantById.get(roommateId); if (outcome === 'delete_both') { // Delete the roommate too — whole container gone. if (roommate) deleteGardenItemNow(roommateId); } else if (outcome === 'standalone') { // Make the roommate standalone — unlink it from the container. if (roommate) { onUpdatePlant(roommateId, { sharedContainerId: undefined, sharedContainerNote: undefined }); // Soft nudge: "Do you need to update the setup?" setRepotReminder({ title: 'Now standalone', message: `${getSavedPlantDisplayName(roommate)} is no longer in a shared container. Do you need to update its setup?`, }); } } else if (outcome === 'repotted') { // I repotted the roommate — it's standalone now, and setup is stale. if (roommate) { onUpdatePlant(roommateId, { sharedContainerId: undefined, sharedContainerNote: undefined }); // Firm nudge with a tappable "update setup" action (power to the user): // two buttons — complete repot (skip setup) or complete repot + update setup. setRepotReminder({ title: 'Repotted!', message: `${getSavedPlantDisplayName(roommate)} is now in its own pot. Don't forget to update its setup.`, }); // Open the repot setup modal so the user can update setup in one action. setShowRepotSetupModal(true); } } }; // Save the repot setup (soil/pot/photo) AND record the repot event in one // action (Bekky, 2026-08-15). Called from the RepotSetupModal's "Save and Repot". const saveRepotSetup = (input: { potType: PlantSetupProfile['potType']; potSize: PlantSetupProfile['potSize']; drainage: PlantSetupProfile['drainage']; mediumTypes: PlantSetupProfile['mediumTypes']; customMedium?: string; customMediumComponents?: string[]; setupPhotoUri?: string; setupPhotos: GardenContextPhoto[]; }) => { if (selectedPlant?.kind !== 'saved' || repotSetupSaving) return; setRepotSetupSaving(true); const plant = selectedPlant.plant; const plantKey = savedGardenKey(plant.id); const existing = setupByPlantId.get(plantKey); const setupInput = { plantId: plantKey, roomId: resolvePlantSpaceId(plant) || undefined, distanceFromWindow: 'unsure' as const, potType: input.potType, potSize: input.potSize, drainage: input.drainage, mediumType: input.mediumTypes?.[0] as PlantSetupProfile['mediumType'] || 'unsure', mediumTypes: input.mediumTypes || [], customMedium: input.customMedium, customMediumComponents: (input.customMediumComponents || []).filter(v => v !== 'unsure'), wateringMethod: 'unsure' as const, usesDedicatedArtificialLight: false, dedicatedArtificialLightTypes: [], setupPhotoUri: input.setupPhotoUri, setupPhotos: input.setupPhotos, photoUris: contextPhotoUris(input.setupPhotos), }; if (existing) { setPlantSetups(current => current.map(setup => setup.id === existing.id ? updatePlantSetupProfile(setup, setupInput) : setup)); } else { setPlantSetups(current => [createPlantSetupProfile(setupInput), ...current]); } onUpdatePlant(plant.id, { setupCompleted: true }); // Record the repot event. const existingEvents = getAllSavedPlantEvents(plant); const now = new Date().toISOString(); const repotEvent: PlantEvent = { id: createGardenId('event'), plantId: plantKey, createdAt: now, eventDate: now, type: 'repotting', eventCategory: 'action', title: 'Repotted', note: 'Repotted this plant.', source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'not_applicable', }; const nextEvents = [repotEvent, ...existingEvents].sort( (a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime() ); // Repot resets the observed care history (Bekky, 2026-08-21, Phase 3): fresh // soil + new pot hold water differently, so the old feedback adjustment is // no longer valid. Clear the careLog + feedbackAdjustment so the feedback // loop restarts clean. The care guidance itself is re-tailored below. const repotSchedule = plant.careSchedule ? { ...plant.careSchedule, careLog: [], feedbackAdjustment: {}, } : undefined; onUpdatePlant(plant.id, { events: nextEvents, plantEvents: nextEvents, updatedAt: now, careSchedule: repotSchedule, }); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plant.id ? { kind: 'saved', plant: { ...current.plant, events: nextEvents, plantEvents: nextEvents } } : current); setShowRepotSetupModal(false); setRepotSetupSaving(false); setCuttingConfirmation({ title: 'Repotted!', message: `${getSavedPlantDisplayName(plant)} has been repotted and its setup updated.`, }); // Re-tailor care to the NEW pot/soil/setup (Bekky, 2026-08-16, Option 1). // A repot is inherently a care-relevant event — the AI must know the plant // was just repotted (fresh soil, new pot, possible shock) before it writes // a care plan or diagnoses a post-repot problem. So we FORCE the refresh // (skip the "Nothing changed" guard) and build the context packet from the // freshly-recorded setup — NOT from React state, which hasn't committed the // new setup yet (setPlantSetups is async). Fire-and-forget + silent retry, // so the user isn't blocked and the update lands even if they navigate away. const newSetup = existing ? updatePlantSetupProfile(existing, setupInput) : createPlantSetupProfile(setupInput); const repotRoom = plant.spaceId ? rooms.find(r => r.id === plant.spaceId) : undefined; refreshPlantIntelligence({ force: true, packet: buildCareContextPacket({ plant, room: repotRoom, setup: newSetup, weather: weatherSnapshot ?? undefined }), }); }; const updateEnvironmentPhotos = (updater: (photos: GardenContextPhoto[]) => GardenContextPhoto[]) => { setSpaceForm(current => { const nextPhotos = updater(normalizeContextPhotos(current.environmentPhotos, [current.environmentPhotoUri, current.photoUri])).slice(0, maxContextPhotos); const firstUri = nextPhotos[0]?.uri; return { ...current, environmentPhotos: nextPhotos, environmentPhotoUri: firstUri, photoUri: firstUri }; }); }; const addEnvironmentPhoto = () => { openPhotoPickerWithGuidance('environment', (uri, extra) => updateEnvironmentPhotos(photos => photos.length >= maxContextPhotos ? photos : [...photos, makeContextPhoto(uri, undefined, extra)])); }; const updateEnvironmentPhotoNote = (photoId: string, note: string) => { updateEnvironmentPhotos(photos => photos.map(photo => photo.id === photoId ? { ...photo, note } : photo)); }; const removeEnvironmentPhoto = (photoId: string) => { updateEnvironmentPhotos(photos => photos.filter(photo => photo.id !== photoId)); }; const updateSetupPhotos = (updater: (photos: GardenContextPhoto[]) => GardenContextPhoto[]) => { setSetupForm(current => { const nextPhotos = updater(normalizeContextPhotos(current.setupPhotos, [current.setupPhotoUri, current.photoUris])).slice(0, maxContextPhotos); const firstUri = nextPhotos[0]?.uri; return { ...current, setupPhotos: nextPhotos, setupPhotoUri: firstUri, photoUris: contextPhotoUris(nextPhotos) }; }); }; const addSetupPhoto = () => { openPhotoPickerWithGuidance('setup', (uri, extra) => updateSetupPhotos(photos => photos.length >= maxContextPhotos ? photos : [...photos, makeContextPhoto(uri, undefined, extra)])); }; const updateSetupPhotoNote = (photoId: string, note: string) => { updateSetupPhotos(photos => photos.map(photo => photo.id === photoId ? { ...photo, note } : photo)); }; const removeSetupPhoto = (photoId: string) => { updateSetupPhotos(photos => photos.filter(photo => photo.id !== photoId)); }; const openPlantEventForm = (type: PlantMemoryEventType = 'progress_photo', pickPhotoImmediately = false) => { const group = memoryCategoryGroups.find(g => g.options.some(o => o.value === type)); setPlantEventForm({ ...makeEmptyPlantEventForm(type), eventCategory: group?.category || 'custom', showCategoryPicker: !pickPhotoImmediately, }); setActiveForm('event'); // Scroll to event form after layout settles setTimeout(() => { const ref = eventFormCardRef.current; if (ref && detailScrollRef.current) { ref.measureLayout( detailScrollRef.current as unknown as number, (_x, y) => { detailScrollRef.current?.scrollTo({ y: Math.max(y - 8, 0), animated: true }); }, () => {} ); } }, 400); if (pickPhotoImmediately) { setTimeout(() => { pickGardenPhoto(uri => { setPlantEventForm(current => ({ ...current, photoUri: uri })); }); }, 160); } }; const openEditPlantEventForm = (event: PlantEvent) => { const editableType = plantMemoryEventOptions.some(option => option.value === event.type) ? event.type as PlantMemoryEventType : 'custom'; const group = memoryCategoryGroups.find(g => g.options.some(o => o.value === editableType)); setPlantEventForm({ editingEventId: event.id, type: editableType, eventCategory: event.eventCategory as PlantEventFormState['eventCategory'] || group?.category || 'custom', showCategoryPicker: false, title: event.title || '', note: event.note || '', photoUri: event.photoUri, eventDate: event.eventDate || event.createdAt || new Date().toISOString(), }); setActiveForm('event'); // Scroll event form to top of viewport setTimeout(() => { const ref = eventFormCardRef.current; if (ref && detailScrollRef.current) { ref.measureLayout( detailScrollRef.current as unknown as number, (_x, y) => { detailScrollRef.current?.scrollTo({ y: Math.max(y - 8, 0), animated: true }); }, () => {} ); } }, 400); }; const openPlantEventDatePicker = () => { const currentDate = new Date(plantEventForm.eventDate); DateTimePickerAndroid.open({ value: Number.isNaN(currentDate.getTime()) ? new Date() : currentDate, mode: 'date', onChange: (event: DateTimePickerEvent, selectedDate?: Date) => { if (event.type === 'dismissed' || !selectedDate) return; setPlantEventForm(current => ({ ...current, eventDate: selectedDate.toISOString() })); }, }); }; const savePlantEventForm = () => { if (savingGardenForm || selectedPlant?.kind !== 'saved') return; if (plantEventForm.type === 'progress_photo' && !plantEventForm.photoUri) { Alert.alert('Photo needed', 'Add a photo before saving this progress photo.'); return; } setSavingGardenForm(true); const plantId = selectedPlant.plant.id; const existingEvents = getAllSavedPlantEvents(selectedPlant.plant); const title = plantEventForm.title.trim() || plantMemoryDefaultTitles[plantEventForm.type]; const now = new Date().toISOString(); const nextEvent: PlantEvent = { id: plantEventForm.editingEventId || createGardenId('event'), plantId: savedGardenKey(plantId), createdAt: existingEvents.find(event => event.id === plantEventForm.editingEventId)?.createdAt || now, eventDate: plantEventForm.eventDate || now, type: plantEventForm.type, eventCategory: plantEventForm.eventCategory, title, note: plantEventForm.note.trim() || undefined, photoUri: plantEventForm.photoUri, source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'not_applicable', }; const nextEvents = ( plantEventForm.editingEventId ? existingEvents.map(event => event.id === plantEventForm.editingEventId ? { ...event, ...nextEvent } : event) : [nextEvent, ...existingEvents] ).sort((a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime()); const nextProgressPhotoUris = nextEvents .filter(event => event.type === 'progress_photo' && event.photoUri && !event.isDeleted && !event.deletedAt) .map(event => event.photoUri as string); const patch = { events: nextEvents, plantEvents: nextEvents, progressPhotoUris: nextProgressPhotoUris, updatedAt: new Date().toISOString(), } as Partial; onUpdatePlant(plantId, patch); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plantId ? { kind: 'saved', plant: { ...current.plant, ...patch } } : current); // Pixie Intelligence: analyze progress/memory photos if (!plantEventForm.editingEventId && nextEvent.photoUri) { triggerPhotoIntelligence( [{ id: nextEvent.id, uri: nextEvent.photoUri, createdAt: now }], 'progress', savedGardenKey(plantId), getSavedPlantDisplayName(selectedPlant.plant), selectedPlant.plant.scientificName, ); } setTimeout(() => { setSavingGardenForm(false); setActiveForm(null); setPlantEventForm(makeEmptyPlantEventForm()); scrollToOriginatingSection(); }, 180); }; const deletePlantEvent = (eventId: string) => { if (selectedPlant?.kind !== 'saved') return; const plantId = selectedPlant.plant.id; const now = new Date().toISOString(); const nextEvents = getAllSavedPlantEvents(selectedPlant.plant).filter(event => event.id !== eventId); const nextProgressPhotoUris = nextEvents .filter(event => event.type === 'progress_photo' && event.photoUri) .map(event => event.photoUri as string); const patch = { events: nextEvents, plantEvents: nextEvents, progressPhotoUris: nextProgressPhotoUris, updatedAt: now, } as Partial; onUpdatePlant(plantId, patch); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plantId ? { kind: 'saved', plant: { ...current.plant, ...patch } } : current); setPendingEventDelete(null); setActiveForm(null); setPlantEventForm(makeEmptyPlantEventForm()); scrollToOriginatingSection(); }; const gardenFormCard = activeForm === 'plant' ? (
{ setActiveForm(null); setEditingPlantId(null); if (plantFormReturnDetail) { setGardenSection('spaces'); setGardenDetail(plantFormReturnDetail.kind === 'space' && !rooms.some(room => room.id === plantFormReturnDetail.id) ? null : plantFormReturnDetail); setPlantFormReturnDetail(null); } scrollToOriginatingSection(); })} accessibilityRole="button" accessibilityLabel="Close plant form"> Cancel setPlantForm(current => ({ ...current, name }))} placeholder="Plant name / common name" /> setPlantForm(current => ({ ...current, scientificName }))} placeholder="Scientific name (optional)" /> Type setPlantForm(current => ({ ...current, plantType }))} /> recordDetailFieldLayout({ form: 'plant', field: 'assignedSpace' }, event.nativeEvent.layout.y)}> Assigned space {rooms.length ? ( a.name.localeCompare(b.name)).map(room => ({ value: room.id, label: room.name }))]} value={plantForm.spaceId || 'none'} onChange={spaceId => setPlantForm(current => ({ ...current, spaceId: spaceId === 'none' ? null : spaceId }))} /> ) : Create a space to tell Pixie where this plant lives.} Growth stage setPlantForm(current => ({ ...current, growthStage }))} /> Time owned setPlantForm(current => ({ ...current, timeOwned }))} /> setPlantForm(current => ({ ...current, notes }))} placeholder="Notes (optional)" multiline /> pickGardenPhoto(uri => setPlantForm(current => ({ ...current, photoUri: uri })))} /> { setActiveForm(null); setEditingPlantId(null); if (plantFormReturnDetail) { setGardenSection('spaces'); setGardenDetail( plantFormReturnDetail.kind === 'space' && !rooms.some(room => room.id === plantFormReturnDetail.id) ? null : plantFormReturnDetail, ); setPlantFormReturnDetail(null); } }} disabled={!plantForm.name.trim()} saving={savingGardenForm} /> ) : activeForm === 'space' ? (
{ setActiveForm(null); setEditingSpaceId(null); if (gardenFormReturnDetail) { setGardenDetail(gardenFormReturnDetail); setGardenFormReturnDetail(null); } scrollToOriginatingSection(); })} accessibilityRole="button" accessibilityLabel="Close space form"> Cancel { if (!spaceNameTouched) setSpaceNameTouched(true); setSpaceForm(current => ({ ...current, name })); }} placeholder="Space name" placeholderTextColor="#7A704D" style={[s.gardenInput, { flex: 1 }, (spaceNameTouched || spaceSaveAttempted) && !spaceForm.name.trim() && { borderColor: '#E84B3A' }]} /> {spaceNameTouched && !spaceForm.name.trim() ? ( ⚠️ ) : null} Space Type Inside setSpaceForm(current => ({ ...current, spaceType, outdoorSunTimings: isOutdoorSpaceType(spaceType) ? current.outdoorSunTimings || [] : [], outdoorLightQuality: isOutdoorSpaceType(spaceType) ? current.outdoorLightQuality || '' as any : '' as any, }))} /> Outside setSpaceForm(current => ({ ...current, spaceType, outdoorSunTimings: isOutdoorSpaceType(spaceType) ? current.outdoorSunTimings || [] : [], outdoorLightQuality: isOutdoorSpaceType(spaceType) ? current.outdoorLightQuality || '' as any : '' as any, }))} /> {/* Elevation — applies to ALL spaces (Bekky, 2026-08-23): higher floors get more sun/wind, ground is more sheltered/humid. */} Elevation What floor is this space on? Higher floors get more sun and wind; ground level is more sheltered but can be more humid. setSpaceForm(current => ({ ...current, elevation }))} /> recordDetailFieldLayout({ form: 'space', field: 'environmentDetails' }, event.nativeEvent.layout.y)}> {isIndoorSpaceType(spaceForm.spaceType) ? ( <> Window direction Pick every direction your windows face — e.g. East and South for two separate windows. setSpaceForm(current => { const selected = current.windowDirections || []; const next = dir as WindowDirection; // 'none' and 'unsure' are exclusive — selecting either clears all // directions; selecting a direction clears 'none'/'unsure'. if (next === 'none' || next === 'unsure') { return { ...current, windowDirections: [next] }; } const withoutExclusive = selected.filter(d => d !== 'none' && d !== 'unsure'); const windowDirections = withoutExclusive.includes(next) ? withoutExclusive.filter(d => d !== next) : [...withoutExclusive, next]; return { ...current, windowDirections }; })} /> {spaceForm.windowDirections?.length && !spaceForm.windowDirections.includes('none') && !spaceForm.windowDirections.includes('unsure') ? ( <> How often do you open it? setSpaceForm(current => ({ ...current, windowOpenFrequency }))} /> ) : null} Light setSpaceForm(current => ({ ...current, lightProfile }))} /> ) : ( <> Sun timing setSpaceForm(current => { const selected = current.outdoorSunTimings || []; const nextSunTiming = sunTiming as OutdoorSunTiming; const outdoorSunTimings = selected.includes(nextSunTiming) ? selected.filter(value => value !== nextSunTiming) : nextSunTiming === 'all_day_sun' ? ['all_day_sun' as OutdoorSunTiming] : [...selected.filter(value => value !== 'all_day_sun'), nextSunTiming]; return { ...current, outdoorSunTimings }; })} /> Light quality setSpaceForm(current => ({ ...current, outdoorLightQuality }))} /> {/* OUTDOOR EXPOSURE (Bekky, 2026-08-23): how covered/sheltered this space is — changes how weather (rain/sun/wind) reaches the plants. */} Roof / covering What's overhead? A solid roof blocks rain and sun, glass blocks rain but lets sun through, lattice/vines let some of both plus wind through. setSpaceForm(current => ({ ...current, exposure: { ...current.exposure, roofType, glassTint: roofType === 'glass' ? current.exposure?.glassTint : undefined } }))} /> {spaceForm.exposure?.roofType === 'glass' ? ( <> Glass type UV-tinted or frosted glass cuts heat build and light; clear glass traps the most heat. setSpaceForm(current => ({ ...current, exposure: { ...current.exposure, glassTint } }))} /> ) : null} {/* Walls + open-faces only make sense when there's a roof — an open-sky space isn't enclosed by anything (Bekky, 2026-08-23). */} {spaceForm.exposure?.roofType && spaceForm.exposure.roofType !== 'none' ? ( <> How many sides are enclosed? setSpaceForm(current => ({ ...current, exposure: { ...current.exposure, walls } }))} /> Open sides face Which directions do the open sides face? Tap all that apply (e.g. North + East). Rain/wind can still reach plants through an uncovered opening. setSpaceForm(current => { const selected = current.exposure?.openFaces || []; const next = selected.includes(facing as 'north') ? selected.filter(v => v !== (facing as 'north')) : [...selected, facing as 'north']; return { ...current, exposure: { ...current.exposure, openFaces: next } }; })} /> ) : null} )} Uses ambient artificial light General light in this space, like ceiling lights, room LEDs, greenhouse lighting, or patio lighting. setSpaceForm(current => ({ ...current, usesAmbientArtificialLight: value === 'yes', ambientArtificialLightTypes: value === 'yes' ? current.ambientArtificialLightTypes || [] : [], ambientArtificialLightHoursPerDay: value === 'yes' ? current.ambientArtificialLightHoursPerDay : undefined, }))} /> {spaceForm.usesAmbientArtificialLight ? ( <> Ambient light type(s) setSpaceForm(current => { const selected = current.ambientArtificialLightTypes || []; const ambientArtificialLightTypes = selected.includes(lightType as ArtificialLightType) ? selected.filter(value => value !== lightType) : [...selected, lightType as ArtificialLightType]; return { ...current, ambientArtificialLightTypes }; })} /> setSpaceForm(current => ({ ...current, ambientArtificialLightHoursPerDay: normalizeLightHoursInput(value) }))} placeholder="Hours per day, optional" /> ) : null} Humidity setSpaceForm(current => ({ ...current, humidityProfile }))} /> Temperature setSpaceForm(current => ({ ...current, temperatureEstimate }))} /> {isIndoorSpaceType(spaceForm.spaceType) ? ( <> Climate & air Which climate devices are in this space, and when do you use them? Pixie uses this to fine-tune watering. {([ { key: 'airCon', label: '❄️ Air con', serious: true }, { key: 'heater', label: '🔥 Heater', serious: true }, { key: 'fan', label: '🌀 Fan', serious: false }, { key: 'humidifier', label: '💧 Humidifier', serious: false }, ] as const).map(({ key, label, serious }) => { // Coerce legacy shapes to the current model on read so old saved // spaces never break: boolean -> {freq:'always'|'never'}; bare // frequency string -> {freq}; already-nested object -> as-is. const raw = (spaceForm.climateDevices || {})[key] as unknown; const freq = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? ((raw as { freq?: unknown }).freq as ClimateDeviceFrequency | undefined) || 'never' : raw === true ? 'all_the_time' : raw === false ? 'never' : (raw as ClimateDeviceFrequency) || 'never'; const reach = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? ((raw as { reach?: unknown }).reach as FeltEngagement | undefined) : freq === 'never' ? 'never' : freq === 'all_the_time' ? 'basically_always' : undefined; const heatType = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? ((raw as { heatType?: unknown }).heatType as HeatType | undefined) : undefined; const setDevice = (patch2: Partial<{ reach: FeltEngagement; heatType: HeatType; freq: ClimateDeviceFrequency }>) => setSpaceForm(currentForm => { const current = (currentForm.climateDevices || {})[key] as unknown; const curObj = (current && typeof current === 'object' && !Array.isArray(current)) ? current as { reach?: FeltEngagement; heatType?: HeatType; freq?: ClimateDeviceFrequency } : {}; const base: Record = { ...(currentForm.climateDevices || {}) }; if (serious) { // Serious devices: reach (+ heatType for heater) ONLY — never freq. base[key] = { reach: 'when_hot_or_cold' as FeltEngagement, ...(curObj.reach ? { reach: curObj.reach } : {}), ...(curObj.heatType ? { heatType: curObj.heatType } : {}), ...(patch2.reach ? { reach: patch2.reach } : {}), ...(patch2.heatType ? { heatType: patch2.heatType } : {}), ...(key === 'heater' ? { heatType: (patch2.heatType || curObj.heatType) as HeatType | undefined } : {}), }; } else { base[key] = patch2.freq ?? 'never'; } return { ...currentForm, climateDevices: base as never }; }); return ( {label} {serious ? ( setDevice({ reach: r as FeltEngagement })} /> ) : ( setDevice({ freq: f as ClimateDeviceFrequency })} /> )} {key === 'heater' && reach !== 'never' && ( What kind of heater? setDevice({ heatType: h as HeatType })} /> )} ); })} ) : null} setSpaceForm(current => ({ ...current, notes }))} placeholder="Notes (optional)" placeholderTextColor="#7A704D" style={[s.gardenInput, s.gardenNotesInput]} multiline /> recordDetailFieldLayout({ form: 'space', field: 'environmentPhoto' }, event.nativeEvent.layout.y)} > {(() => { const environmentPhotos = normalizeContextPhotos(spaceForm.environmentPhotos, [spaceForm.environmentPhotoUri, spaceForm.photoUri]); const canAddEnvironmentPhoto = environmentPhotos.length < maxContextPhotos; return ( <> Environment photos showPhotoGuidance('environment'))} accessibilityRole="button" accessibilityLabel="How to take a good environment photo" > ? Help Pixie understand light, placement, airflow, and nearby plants. This helps future care guidance become more accurate. {environmentPhotos.length ? ( <> {environmentPhotos.map((photo, index) => ( setPlacementViewerUri(photo.uri))}> Photo {index + 1} setSpaceForm(current => ({ ...current, environmentPhotos: (current.environmentPhotos || []).map(p => p.id === photo.id ? { ...p, note } : p), }))} placeholder="What does this photo show? Optional" placeholderTextColor="#7A704D" style={s.contextPhotoInput} multiline /> setPendingPhotoDelete({ kind: 'environment', id: photo.id, confirm: () => removeEnvironmentPhoto(photo.id), }))} accessibilityRole="button" accessibilityLabel="Remove environment photo" > Remove ))} ) : ( No environment photos yet. Add photos of the plant's placement, light source, or nearby surroundings. )} {canAddEnvironmentPhoto ? 'Add Environment Photo' : '5 environment photos added'} ); })()} {savingGardenForm ? : Save} ) : activeForm === 'setup' ? (
{ setActiveForm(null); setEditingSetupPlantId(null); scrollToOriginatingSection(); })} accessibilityRole="button" accessibilityLabel="Close setup form"> Cancel Assigned Space {rooms.find(room => room.id === setupForm.roomId)?.name || 'No assigned space'} {rooms.find(room => room.id === setupForm.roomId) ? {formatSetupSpaceEnvironment(rooms.find(room => room.id === setupForm.roomId)!)} : null} Use the Environment tile to change this plant's space. {/* "How is it planted?" gate (Bekky, 2026-08-22): the FIRST setup question. Everything below stays hidden until answered, then only the relevant follow-ups appear — pot questions for a pot, none for in-ground/hydro. */} recordDetailFieldLayout({ form: 'setup', field: 'containerType' }, event.nativeEvent.layout.y)}> How is it planted? setSetupForm(current => { // Choosing a non-pot option collapses the pot questions. const potCollapsed = plantedIn === 'ground' || plantedIn === 'raised_bed' || plantedIn === 'hydroponic'; return { ...current, plantedIn, potType: potCollapsed ? current.potType === 'unsure' ? 'unsure' : current.potType : current.potType, }; })} /> {/* SHARED CONTAINER (Bekky, 2026-09-04): appears DIRECTLY below "How is it planted?" and above "Pot type", and only when the plant is potted or unsure (a shared container only exists if potted; in-ground = never). Tapping it opens the SharedContainerModal to pick roommates (same-space plants) or mark the roommate as not-yet-added. */} {(setupForm.plantedIn === 'pot' || setupForm.plantedIn === 'unsure') ? ( Shared container setShowSharedContainerModal(true))} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, borderWidth: 1, borderColor: setupForm.sharedContainerId ? '#2E6B3F' : 'rgba(191,217,180,0.95)', backgroundColor: setupForm.sharedContainerId ? '#E8F5D3' : '#FFFDF7', paddingHorizontal: 11, paddingVertical: 6, alignSelf: 'flex-start', }} > {setupForm.sharedContainerId ? '🪴 Shares a container' : '🪴 Shared container'} {setupForm.sharedContainerId ? ( This plant shares its pot with other plants. Tap to change roommates. ) : null} ) : null} {/* "In the ground" follow-up (Bekky, 2026-08-22): directly in soil vs a raised bed. A raised bed can use EITHER a brought-in custom mix OR the native ground soil. Shows only when the user picked "In the ground". */} {setupForm.plantedIn === 'ground' ? ( Where exactly? setSetupForm(current => ({ ...current, inGroundKind: inGroundKind as SetupFormState['inGroundKind'] }))} /> ) : null} {/* Pot questions show ONLY once the user answers the gate with a pot-like choice. On a fresh plant (plantedIn undefined) NOTHING below is shown until they pick — the whole point of progressive disclosure. */} {(setupForm.plantedIn && setupForm.plantedIn !== 'ground' && setupForm.plantedIn !== 'raised_bed' && setupForm.plantedIn !== 'hydroponic') ? ( <> recordDetailFieldLayout({ form: 'setup', field: 'containerType' }, event.nativeEvent.layout.y)}> Pot type room.id === setupForm.roomId)?.spaceType)} value={setupForm.potType ? (getEffectivePotTypeForSpace(setupForm.potType, rooms.find(room => room.id === setupForm.roomId)?.spaceType) ?? null) : null} onChange={potType => setSetupForm(current => ({ ...current, potType, potSize: isInGroundPotType(potType) ? 'not_applicable' : current.potSize === 'not_applicable' ? 'unsure' : current.potSize, drainage: isInGroundPotType(potType) ? 'not_applicable' : current.drainage === 'not_applicable' ? 'unsure' : current.drainage, }))} /> {/* LARGE PLANTER HINT (Bekky, 2026-09-04): a large planter is ONE physical container but the plants in it are INDIVIDUALS — they're cared for separately and grouped by need. Use "Shared container" only for plants that share one soil pocket and must be watered together. Prevents the mismatch of marking a 20-plant trough as one shared unit. */} {setupForm.potType === 'shared_planter' ? ( This is a large container. Plants in it are cared for individually — the app groups them by their needs. Use "Shared container" only for plants that share one soil pocket and must be watered together. ) : null} {!isInGroundPotType(getEffectivePotTypeForSpace(setupForm.potType, rooms.find(room => room.id === setupForm.roomId)?.spaceType)) ? ( <> Pot size option.value !== 'not_applicable')} value={(setupForm.potSize === 'not_applicable' ? 'unsure' : setupForm.potSize) ?? null} onChange={potSize => setSetupForm(current => ({ ...current, potSize }))} /> Drainage option.value !== 'not_applicable')} value={(setupForm.drainage === 'not_applicable' ? 'unsure' : setupForm.drainage) ?? null} onChange={drainage => setSetupForm(current => ({ ...current, drainage }))} /> ) : null} ) : null} {/* Medium + Top dressing + Watering also gated until "How is it planted?" is answered (Bekky, 2026-08-22). */} {setupForm.plantedIn ? ( <> recordDetailFieldLayout({ form: 'setup', field: 'medium' }, event.nativeEvent.layout.y)}> {setupForm.plantedIn === 'hydroponic' ? ( <> Kind of setup What does your water setup look like? setSetupForm(current => ({ ...current, hydroSetup: kind as SetupFormState['hydroSetup'] }))} /> {/* No gating on hydro medium — always show, any kind of setup. */} Medium setSetupForm(current => { const selected = current.mediumTypes || []; const mediumTypes = selected.includes(medium) ? selected.filter(v => v !== medium) : [...selected, medium]; return { ...current, mediumTypes, mediumType: mediumTypes[0] as PlantSetupProfile['mediumType'] || 'unsure' }; })} /> ) : ( <> Medium Pick a store-bought mix, add your own, or both — combine freely. Store-bought mixes { // Native ground soil is NOT a store-bought mix — it's free outside, // and belongs in the Custom section below. Custom also lives there. // (Bekky, 2026-08-23.) return o.value !== 'custom' && o.value !== 'garden_soil'; })} values={setupForm.mediumTypes || []} onToggle={medium => setSetupForm(current => { const selected = current.mediumTypes || []; const mediumTypes = selected.includes(medium) ? selected.filter(v => v !== medium) : [...selected, medium]; return { ...current, mediumTypes, mediumType: mediumTypes[0] as PlantSetupProfile['mediumType'] || 'unsure' }; })} /> Custom setSetupForm(current => { const selected = current.customMediumComponents || []; const customMediumComponents = selected.includes(component) ? selected.filter(v => v !== component) : [...selected, component]; return { ...current, customMediumComponents }; })} /> Anything else? setSetupForm(current => ({ ...current, customMedium }))} placeholder="Describe your mix, e.g. orchid bark, perlite, coco coir…" placeholderTextColor="#7A704D" style={s.gardenInput} /> )} {setupForm.plantedIn !== 'hydroponic' ? ( <> recordDetailFieldLayout({ form: 'setup', field: 'medium' }, event.nativeEvent.layout.y)} > Top dressing What's on the surface of the soil — like bark, gravel, or pebbles. Optional. setSetupForm(current => { const selected = current.topDressing || []; const topDressing = selected.includes(value) ? selected.filter(v => v !== value) : [...selected, value]; return { ...current, topDressing }; })} /> {(setupForm.topDressing || []).includes('custom') ? ( setSetupForm(current => ({ ...current, customTopDressing }))} placeholder="What is it? (optional)" placeholderTextColor="#7A704D" style={s.gardenInput} /> ) : null} recordDetailFieldLayout({ form: 'setup', field: 'wateringMethod' }, event.nativeEvent.layout.y)} > Watering method What do you use to water? You can choose more than one. { // In-ground / garden bed / raised bed: no wick, no self-watering // reservoir, no mister/spray bottle (not physically in the ground). // (Bekky, 2026-08-22.) if (setupForm.plantedIn === 'ground' || setupForm.plantedIn === 'raised_bed') { return o.value !== 'wick' && o.value !== 'self_watering' && o.value !== 'mister'; } return true; })} values={setupForm.wateringMethods || []} onToggle={method => setSetupForm(current => { const selected = current.wateringMethods || []; const wateringMethods = selected.includes(method) ? selected.filter(v => v !== method) : [...selected, method]; return { ...current, wateringMethods, wateringMethod: wateringMethods[0] as PlantSetupProfile['wateringMethod'] || 'unsure' }; })} /> {(setupForm.wateringMethods || []).includes('custom') ? ( setSetupForm(current => ({ ...current, customWatering }))} placeholder="What is it? (optional)" placeholderTextColor="#7A704D" style={s.gardenInput} /> ) : null} Watering style Do you water from the top or the bottom? setSetupForm(current => ({ ...current, wateringStyle: style as SetupFormState['wateringStyle'] }))} /> Is your watering system automated? setSetupForm(current => ({ ...current, wateringAutomated: value === 'yes' }))} /> ) : null} ) : null} recordDetailFieldLayout({ form: 'setup', field: 'dedicatedLight' }, event.nativeEvent.layout.y)} >
Light aimed at this specific plant, like a clip-on grow light, shelf light, or lamp. Uses dedicated plant light setSetupForm(current => ({ ...current, usesDedicatedArtificialLight: value === 'yes', dedicatedArtificialLightTypes: value === 'yes' ? current.dedicatedArtificialLightTypes || [] : [], dedicatedArtificialLightHoursPerDay: value === 'yes' ? current.dedicatedArtificialLightHoursPerDay : undefined, dedicatedArtificialLightDistance: value === 'yes' ? current.dedicatedArtificialLightDistance : undefined, }))} /> {setupForm.usesDedicatedArtificialLight ? ( <> Dedicated light type(s) setSetupForm(current => { const selected = current.dedicatedArtificialLightTypes || []; const dedicatedArtificialLightTypes = selected.includes(lightType as ArtificialLightType) ? selected.filter(value => value !== lightType) : [...selected, lightType as ArtificialLightType]; return { ...current, dedicatedArtificialLightTypes }; })} /> setSetupForm(current => ({ ...current, dedicatedArtificialLightHoursPerDay: normalizeLightHoursInput(value) }))} placeholder="Hours per day, optional" /> setSetupForm(current => ({ ...current, dedicatedArtificialLightDistance }))} placeholder="Distance from plant, optional" /> ) : null} Fertilizer {(() => { // FERTILIZER GROUP BOX (Bekky, 2026-09-02, rework): the fertilizer // section lives in a hand-drawn green box. The FIRST field is "this // plant" — always shown, saves to just this plant. If the plant is a // member of a fertilize cohort, a SECOND field appears below it: // "Apply to the whole group too?" — filling it propagates to every // member. Two independent methods (plant-setup vs space-cohort edit), // nothing auto-propagates. const rawPlantId = (editingSetupPlantId?.startsWith(SAVED_PREFIX) ? editingSetupPlantId.slice(SAVED_PREFIX.length) : null); const fertilizeCohortNames = rawPlantId ? rooms.flatMap(room => (room.cohorts?.fertilize || [])) .filter(coh => coh.plantIds.includes(rawPlantId)) .map(coh => coh.name) : []; const inCohort = fertilizeCohortNames.length > 0; return ( setSetupForm(current => ({ ...current, fertilizer }))} placeholder="What do you feed this plant? (e.g. 10-10-10, liquid seaweed) — optional" placeholderTextColor="#7A704D" style={s.gardenInput} /> {inCohort ? ( <> This plant is in the fertilize group{fertilizeCohortNames.length === 1 ? ` "${fertilizeCohortNames[0]}"` : `: ${fertilizeCohortNames.join(', ')}`}. Apply to the whole group? setFertilizerApplyGroup(v === 'yes')} /> ) : null} ); })()} Notes setSetupForm(current => ({ ...current, notes }))} placeholder="Notes (optional)" placeholderTextColor="#7A704D" style={[s.gardenInput, s.gardenNotesInput]} multiline /> recordDetailFieldLayout({ form: 'setup', field: 'setupPhoto' }, event.nativeEvent.layout.y)} > {(() => { const setupPhotos = normalizeContextPhotos(setupForm.setupPhotos, [setupForm.setupPhotoUri, setupForm.photoUris]); const canAddSetupPhoto = setupPhotos.length < maxContextPhotos; return ( <> Setup photos showPhotoGuidance('setup'))} accessibilityRole="button" accessibilityLabel="How to take a good setup photo" > ? Help Pixie understand your pot, soil, drainage, and growing setup. This helps Pixie understand how this plant is actually growing. {setupPhotos.length ? ( <> {setupPhotos.map((photo, index) => ( setPlacementViewerUri(photo.uri))}> Photo {index + 1} setSetupForm(current => ({ ...current, setupPhotos: (current.setupPhotos || []).map(p => p.id === photo.id ? { ...p, note } : p), }))} placeholder="What does this photo show? Optional" placeholderTextColor="#7A704D" style={s.contextPhotoInput} multiline /> setPendingPhotoDelete({ kind: 'setup', id: photo.id, confirm: () => removeSetupPhoto(photo.id), }))} accessibilityRole="button" accessibilityLabel="Remove setup photo" > Remove ))} ) : ( No setup photos yet. Add photos of the pot, soil, drainage, or grow light setup. )} {canAddSetupPhoto ? 'Add Setup Photo' : '5 setup photos added'} ); })()} {savingGardenForm ? : Save Setup} ) : activeForm === 'event' ? (
{ setActiveForm(null); setPlantEventForm(makeEmptyPlantEventForm()); scrollToOriginatingSection(); })} accessibilityRole="button" accessibilityLabel="Close memory form"> Cancel {!plantEventForm.editingEventId && plantEventForm.showCategoryPicker ? ( <> What would you like Pixie to remember? {memoryCategoryGroups.map(group => ( { setPlantEventForm(current => ({ ...current, eventCategory: group.category, type: group.options[0].value, showCategoryPicker: false })); })}> {group.label} {group.description} ))} ) : ( <> {!plantEventForm.editingEventId ? ( setPlantEventForm(current => ({ ...current, showCategoryPicker: true })))}> ← {memoryCategoryGroups.find(g => g.category === plantEventForm.eventCategory)?.label || 'Back'} ) : null} Event type g.category === plantEventForm.eventCategory)?.options || plantMemoryEventOptions} value={plantEventForm.type} onChange={type => setPlantEventForm(current => ({ ...current, type, title: current.title && current.title !== plantMemoryDefaultTitles[current.type] ? current.title : '', }))} /> {plantEventForm.type === 'custom' || plantEventForm.editingEventId ? ( setPlantEventForm(current => ({ ...current, title }))} placeholder="Event title (optional)" /> ) : null} Event date {formatPlantEventDate(plantEventForm.eventDate)} {plantEventForm.type === 'progress_photo' ? 'Progress photo' : 'Photo optional'} {plantEventForm.photoUri ? : null} openPhotoPickerWithGuidance('progress', uri => { setPlantEventForm(current => ({ ...current, photoUri: uri })); setTimeout(() => { const ref = eventFormCardRef.current; if (ref && detailScrollRef.current) { ref.measureLayout(detailScrollRef.current as unknown as number, (_x, y) => { detailScrollRef.current?.scrollTo({ y: Math.max(y - 8, 0), animated: true }); }, () => {}); } }, 220); }))}> {plantEventForm.photoUri ? 'Change Photo' : plantEventForm.type === 'progress_photo' ? 'Add Progress Photo' : 'Add Photo'} {plantEventForm.photoUri && plantEventForm.type !== 'progress_photo' ? ( setPlantEventForm(current => ({ ...current, photoUri: undefined })), haptic.warning)}> Remove Photo ) : null} {plantEventForm.type === 'progress_photo' && !plantEventForm.photoUri ? ( A photo is needed for progress photo events. ) : null} Note setPlantEventForm(current => ({ ...current, note }))} placeholder="Note (optional)" placeholderTextColor="#7A704D" style={[s.gardenInput, s.gardenNotesInput]} multiline /> {plantEventForm.editingEventId ? ( { const event = getAllSavedPlantEvents(selectedPlant?.kind === 'saved' ? selectedPlant.plant : undefined).find(item => item.id === plantEventForm.editingEventId); if (event) setPendingEventDelete(event); }, haptic.warning)} > Delete Event ) : null} {savingGardenForm ? : {plantEventForm.photoUri ? 'Save & let Pixie learn' : 'Save Event'}} )} ) : null; const openPlantDetail = (selection: GardenSelection, returnDetail: GardenDetail = null) => { setActiveForm(null); setPlantReturnDetail(returnDetail); setGardenSection(returnDetail?.kind === 'space' ? 'spaces' : 'plants'); setGardenDetail(null); setSelectedPlant(selection); // NOTE (2026-08-15): the open-plant re-enrich safety net was REMOVED — enrichment // is now manual-only. New plants get their first-fetch guaranteed at save time; // re-enrichment happens only via "Save and refresh Pixie Intelligence" (guarded // by change-detection). Opening a plant never fires the AI. }; // Cadence wheel handler (Bekky, 2026-08-13, reworded 2026-08-30): sets/clears // the last-done DATE for a care action on the currently-open saved plant — // the pencil. There is NO cadence override: careSchedule.overrides was // removed (Bekky, 2026-08-21) and is NOT coming back (Bekky, 2026-08-30). The optional // `lastDone` (local YYYY-MM-DD) anchors the schedule so the NEXT due date is // lastDone + cadence, not today + cadence (Bekky, 2026-08-20). // Phase 2 (Bekky, 2026-08-21): the manual `overrides` is REMOVED — it clashed // with the intelligence. The user's behavior (via the careLog + slider) is now // the override. The cadence wheel now only sets the "last done" date (the // manual "I watered it today" path), which records a careLog entry so the // feedback loop + AI can learn from it. const saveCadenceOverride = (days: number | null, lastDone?: string | null) => { const c = cadenceOverride; setCadenceOverride(null); if (!c || selectedPlant?.kind !== 'saved') return; const schedule = selectedPlant.plant.careSchedule; if (!schedule) return; const lastDoneMap = { ...(schedule.lastDone || {}) }; const previousAnchor = (schedule.lastDone || {})[c.action]; if (lastDone) { lastDoneMap[c.action] = lastDone; } else { delete lastDoneMap[c.action]; } // MANUAL CARE LOG FIX (Bekky, 2026-08-30, chunk B-adjacent): the old // comment here CLAIMED a careLog entry was recorded, but the code never // wrote one — manual corrections were invisible to the feedback loop. // When the user sets an anchor DATE (water/fertilize only — inspect has // no anchor semantics), append a 'manual'-path entry so the AI learns // from the correction. No entry when the user clears the date (nothing // happened; deleting an anchor shouldn't fabricate history). let newLog = schedule.careLog || []; if (lastDone && (c.action === 'water' || c.action === 'fertilize')) { newLog = [...newLog, { action: c.action, triggerDate: lastDone, completedDate: lastDone, actualIntervalDays: previousAnchor ? Math.max(0, Math.round((Date.parse(lastDone) - Date.parse(previousAnchor)) / 86400000)) : 0, stateAtComplete: 'manual-correction', path: 'manual' as const, }]; } onUpdatePlant(selectedPlant.plant.id, { careSchedule: { ...schedule, careLog: newLog, lastDone: lastDoneMap }, }); }; // Per-plant collapsible sections (Bekky, 2026-08-13): toggle a section's // collapsed state on the currently-open saved plant. Persists via // SavedPlantProfile.collapsedSections (saved to disk on update). const toggleSectionCollapsed = (section: string) => { if (selectedPlant?.kind !== 'saved') return; const current = selectedPlant.plant.collapsedSections || []; const next = current.includes(section) ? current.filter(s => s !== section) : [...current, section]; onUpdatePlant(selectedPlant.plant.id, { collapsedSections: next }); }; const isSectionCollapsed = (section: string) => selectedPlant?.kind === 'saved' && (selectedPlant.plant.collapsedSections || []).includes(section); // Care-guidance detail row toggles (Bekky, 2026-08-23): each action // (water/fertilize/inspect) can collapse its long detail body to just the // cadence line. Persists per-plant across restarts via careGuidanceCollapsed. const toggleCareGuidanceCollapsed = (action: string) => { if (selectedPlant?.kind !== 'saved') return; const current = selectedPlant.plant.careGuidanceCollapsed || []; const next = current.includes(action) ? current.filter(a => a !== action) : [...current, action]; onUpdatePlant(selectedPlant.plant.id, { careGuidanceCollapsed: next }); }; const isCareGuidanceCollapsed = (action: string) => selectedPlant?.kind === 'saved' && (selectedPlant.plant.careGuidanceCollapsed || []).includes(action); // "Save and refresh Pixie Intelligence" (Bekky, 2026-08-13): re-enrich the // whole plant's care guidance in ONE AI call using the current environment + // setup + placement context, so care adapts to the plant's real conditions // (e.g. ceramic pot dries slower than plastic → water less often). // // 2026-08-15 change-detection guard: the packet is fingerprinted and stored on // the plant. If the fingerprint is UNCHANGED since the last refresh, the AI // call is SKIPPED (nothing changed — no point spending an AI call re-fetching // identical care). The first tap on a plant (or any tap after a real // environment/setup/placement edit) fires the refresh and stores the new // fingerprint. const [refreshIntelligenceLoading, setRefreshIntelligenceLoading] = useState(false); // One-and-done completion (Bekky, 2026-09-08): the button disappears once the // plant HAS a careSchedule (persistent across restarts — not a per-session // flag). The 24h onboarding window + careSchedule presence gate it, so once // care is generated (manually or by auto-care) the button is gone for good. // Flash/attention cue on the "Save and refresh Pixie Intelligence" button. // Set true right after a setup/environment/placement save (so the user // notices the refresh is the next step) and auto-clears after a short pulse // or once they tap it (Bekky, 2026-08-19). const [refreshCueVisible, setRefreshCueVisible] = useState(false); const refreshCueTimerRef = useRef | null>(null); const refreshCuePulse = useRef(new Animated.Value(0)).current; // Gentle green pulse when the cue is visible — a soft glow that fades in/out. useEffect(() => { if (!refreshCueVisible) { refreshCuePulse.stopAnimation(); refreshCuePulse.setValue(0); return; } const loop = Animated.loop( Animated.sequence([ Animated.timing(refreshCuePulse, { toValue: 1, duration: 800, useNativeDriver: true }), Animated.timing(refreshCuePulse, { toValue: 0, duration: 800, useNativeDriver: true }), ]), ); loop.start(); return () => loop.stop(); }, [refreshCueVisible, refreshCuePulse]); const flashRefreshButton = () => { setRefreshCueVisible(true); if (refreshCueTimerRef.current) clearTimeout(refreshCueTimerRef.current); refreshCueTimerRef.current = setTimeout(() => setRefreshCueVisible(false), 9000); }; // Tracks the currently-viewed saved plant id so a background refresh that // completes AFTER the user navigated away (or to a different plant) does NOT // pop a success modal on the wrong screen — it just updates silently. const selectedPlantIdRef = useRef(null); useEffect(() => { selectedPlantIdRef.current = selectedPlant?.kind === 'saved' ? selectedPlant.plant.id : null; }, [selectedPlant]); // SHARED-CONTAINER NOTE BACKFILL (Bekky, 2026-09-04): the color-coded pairing // note is generated fire-and-forget when roommates are confirmed. If that call // failed silently, or the container was created before the note feature // shipped, the note is missing — so the Container section shows no opinion. // Backfill it when a plant has roommates but no note yet. The ref is only // marked "tried" on SUCCESS — a failure removes it so the next open retries // (a transient AI blip can't permanently block the note). const sharedContainerNoteBackfillRef = useRef>(new Set()); useEffect(() => { if (selectedPlant?.kind !== 'saved') return; const plant = selectedPlant.plant; if (!plant.sharedContainerId) return; if (plant.sharedContainerNote) return; // already has a note const roommates = savedPlants.filter(p => p.id !== plant.id && p.sharedContainerId === plant.sharedContainerId); if (roommates.length < 1) return; const containerKey = plant.sharedContainerId; if (sharedContainerNoteBackfillRef.current.has(containerKey)) return; // already succeeded const memberNames = [plant, ...roommates] .map(p => p.name || p.commonName || p.scientificName || 'Plant') .filter(Boolean); if (memberNames.length < 2) return; import('../services/plantEnrichment').then(mod => mod.generateSharedContainerNote(memberNames) ).then(note => { if (!note) return; // failure — do NOT mark tried, so next open retries sharedContainerNoteBackfillRef.current.add(containerKey); // only mark on success const notePayload = { quality: note.quality, text: note.text, roommateIds: roommates.map(r => r.id).sort(), generatedAt: new Date().toISOString(), }; [plant, ...roommates].forEach(p => { onUpdatePlant(p.id, { sharedContainerNote: notePayload }); }); }).catch(() => {}); // failure — not marked tried, retries next open }, [selectedPlant, savedPlants, onUpdatePlant]); // "Save and refresh Pixie Intelligence" (Bekky, 2026-08-15): re-enrich the // whole plant's care guidance in ONE AI call using the current environment + // setup + placement context, so care adapts to the plant's real conditions. // // FIRE-AND-FORGET + SILENT RETRY (Bekky, 2026-08-15): tapping the button // starts the fetch in the background. If the user navigates away or does // something else, the fetch keeps running and updates the plant when done — // onUpdatePlant is an App-level callback that works regardless of this // screen's mount state. On failure it retries silently with backoff until it // succeeds (bounded). The success modal ONLY shows if the user is still // viewing this exact plant; otherwise the update lands silently. const refreshPlantIntelligence = (opts?: { force?: boolean; packet?: CareContextPacket }) => { if (selectedPlant?.kind !== 'saved') return; const plant = selectedPlant.plant; // The context packet drives BOTH the change-detection fingerprint and the // AI prompt. Callers can pass an explicit packet (e.g. a freshly-recorded // repot setup) so the AI sees the NEW state even before React commits the // setup state update; otherwise we build it from current state. const room = plant.spaceId ? rooms.find(r => r.id === plant.spaceId) : undefined; const setup = setupByPlantId.get(savedGardenKey(plant.id)); const packet = opts?.packet || buildCareContextPacket({ plant, room, setup, weather: weatherSnapshot ?? undefined }); const contextPacket = renderCareContextPacket(packet); const fingerprint = fingerprintContextPacket(packet); // CHANGE-DETECTION (text): if nothing in the AI-relevant text context // changed since the last refresh, skip the text-enrichment call. A forced // refresh (e.g. after a repot) ALWAYS runs — a repot is inherently a // care-relevant event even if the setup fields happened to be unchanged. const textChanged = opts?.force || !plant.contextFingerprint || plant.contextFingerprint !== fingerprint; // Gather ALL context photos (environment + setup + placement) for this // plant. Environment photos live on the space and are shared by every // plant in it; setup photos live on the setup profile; placement photos on // the plant. We fingerprint the combined photo set so re-analysis only // fires when a photo actually changed (Bekky, 2026-08-19). const environmentPhotos = room ? normalizeContextPhotos(room.environmentPhotos, [room.environmentPhotoUri, room.photoUri]) : []; const setupPhotos = setup ? normalizeContextPhotos(setup.setupPhotos, [setup.setupPhotoUri, setup.photoUris]) : []; const placementPhotos = normalizeContextPhotos(plant.placementPhotos, plant.placementPhoto ? [plant.placementPhoto] : []); const allContextPhotos = [...environmentPhotos, ...setupPhotos, ...placementPhotos]; const photoFingerprint = fingerprintContextPhotos(allContextPhotos.map(p => p.uri)); // Only photos whose set differs from the last refresh need (re)analysis. const photosChanged = !plant.contextPhotoFingerprint || plant.contextPhotoFingerprint !== photoFingerprint; // PER-SECTION learning status (Bekky, 2026-08-19): the user wants the UI to // reflect WHAT Pixie is learning from — the chips/choices (text) and/or the // photos — per section (environment / setup / placement). Compute each // section's text + photo fingerprints and compare against what was stored // on the last refresh, so we can say "learning from your setup" vs // "learning from your setup and photos" accurately. const sectionTextFingerprints = fingerprintContextSections(packet); const sectionPhotoFingerprints = fingerprintContextSectionPhotos({ environment: environmentPhotos.map(p => p.uri), setup: setupPhotos.map(p => p.uri), placement: placementPhotos.map(p => p.uri), }); const prevSectionText = plant.contextSectionFingerprints || {}; const prevSectionPhoto = plant.contextSectionPhotoFingerprints || {}; // A section only counts as "changed" for the per-section message if it has a // BASELINE to compare against. Plants refreshed before per-section // fingerprints existed have no baseline — flagging them as changed would // falsely claim e.g. "placement data" when only the space changed (Bekky, // 2026-08-19). The whole-plant refresh still runs; we just don't show a // per-section message for sections we can't actually diff. const sectionChanged = (section: 'environment' | 'setup' | 'placement') => { const hasTextBaseline = prevSectionText[section] !== undefined; const hasPhotoBaseline = prevSectionPhoto[section] !== undefined; const textChanged = (opts?.force || hasTextBaseline) && (opts?.force || prevSectionText[section] !== sectionTextFingerprints[section]); const photoChanged = hasPhotoBaseline && prevSectionPhoto[section] !== sectionPhotoFingerprints[section]; return { textChanged, photoChanged }; }; const sectionLabel: Record<'environment' | 'setup' | 'placement', string> = { environment: 'environment', setup: 'setup', placement: 'placement', }; // Set a per-section "learning" status for every section that changed // (text and/or photos). Only changed sections show a status — unchanged // sections stay quiet (Bekky: "if nothing changed, don't include it"). // // ENVIRONMENT + PLACEMENT message (Bekky, 2026-08-19, final): // - placement changed, environment NOT changed → "your placement data" // - placement changed AND environment changed → "your environment and placement data" // - only environment changed → "your environment data" // The deciding factor is whether the ENVIRONMENT/space data actually changed // (envChanged), NOT whether a space exists — placement can change while the // space stays untouched. We set it on the 'environment' key (the render // shows environment?.message || placement?.message) and clear 'placement'. const envChanged = sectionChanged('environment'); const placementChanged = sectionChanged('placement'); const envOrPlacementChanged = envChanged.textChanged || envChanged.photoChanged || placementChanged.textChanged || placementChanged.photoChanged; if (envOrPlacementChanged) { const placementTouched = placementChanged.textChanged || placementChanged.photoChanged; const envTouched = envChanged.textChanged || envChanged.photoChanged; const envPhotos = envChanged.photoChanged || placementChanged.photoChanged; const what = placementTouched ? (envTouched ? (envPhotos ? 'your environment and placement data and photos' : 'your environment and placement data') : (envPhotos ? 'your placement data and photos' : 'your placement data')) : (envPhotos ? 'your environment data and photos' : 'your environment data'); showIntelligenceMessage(`Pixie is learning from ${what}...`, 'analyzing', 'environment'); setSectionMessages(current => ({ ...current, placement: undefined })); } const setupChanged = sectionChanged('setup'); if (setupChanged.textChanged || setupChanged.photoChanged) { const what = setupChanged.textChanged && setupChanged.photoChanged ? 'your setup and photos' : setupChanged.textChanged ? 'your setup' : 'your setup photos'; showIntelligenceMessage(`Pixie is learning from ${what}...`, 'analyzing', 'setup'); } // If NOTHING changed (no text change AND no photo change), skip entirely. if (!textChanged && !photosChanged) { setCuttingConfirmation({ title: 'Nothing changed ✨', message: `${getSavedPlantDisplayName(plant)}'s care is already up to date.`, actionText: 'Refresh anyway', onAction: () => refreshPlantIntelligence({ force: true }), }); return; } const plantId = plant.id; const commonName = plant.commonName || plant.name || ''; const scientificName = plant.scientificName; // SHARED CONTAINER (Bekky, 2026-09-04): if this plant shares a container, // the care guidance is generated ONCE for the whole container (judging both // species together, compromise cadence + not-ideal note) and written to ALL // roommates. Build the roommate context for the AI prompt. const roommatesForRefresh = plant.sharedContainerId ? savedPlants.filter(p => p.id !== plant.id && p.sharedContainerId === plant.sharedContainerId) : []; const containerContext = roommatesForRefresh.length ? roommatesForRefresh.map(r => r.name || r.commonName || r.scientificName || 'Plant').join(', ') : undefined; const containerMemberIds = roommatesForRefresh.length ? [plantId, ...roommatesForRefresh.map(r => r.id)] : [plantId]; const displayName = getSavedPlantDisplayName(plant); setRefreshIntelligenceLoading(true); // If the context PHOTOS changed, fire photo analysis (fire-and-forget, // best-effort) alongside the text enrichment. analyzeAndStorePhoto skips // photos already analyzed/analyzing, so unchanged photos cost nothing. // NOTE: plantId for the intelligence profile must be savedGardenKey(plant.id) // — that's how the profile is keyed (see the progress/memory path). if (photosChanged && allContextPhotos.length > 0) { const profileKey = savedGardenKey(plantId); // Group by type so each photo is analyzed with the right context. const photoGroups: { photos: GardenContextPhoto[]; type: 'environment' | 'placement' | 'setup' }[] = [ { photos: environmentPhotos, type: 'environment' }, { photos: placementPhotos, type: 'placement' }, { photos: setupPhotos, type: 'setup' }, ]; for (const group of photoGroups) { if (group.photos.length) { // Section-aware learning message so the photo analysis doesn't // overwrite the per-section status (Bekky, 2026-08-19). Environment // and placement fold into one "environment and placement data" // message; setup keeps its own. if (group.type === 'environment' || group.type === 'placement') { const placementTouched = placementChanged.textChanged || placementChanged.photoChanged; const envTouched = envChanged.textChanged || envChanged.photoChanged; const envPhotos = envChanged.photoChanged || placementChanged.photoChanged; const learningWhat = placementTouched ? (envTouched ? (envPhotos ? 'your environment and placement data and photos' : 'your environment and placement data') : (envPhotos ? 'your placement data and photos' : 'your placement data')) : (envPhotos ? 'your environment data and photos' : 'your environment data'); const learningMessage = `Pixie is learning from ${learningWhat}...`; const learnedMessage = `Pixie learned from ${learningWhat}.`; triggerPhotoIntelligence( group.photos, group.type, profileKey, commonName, scientificName, learningMessage, learnedMessage, ); } else { const { textChanged, photoChanged } = sectionChanged(group.type); const learningMessage = textChanged && photoChanged ? `Pixie is learning from your ${sectionLabel[group.type]} and photos...` : `Pixie is learning from your ${sectionLabel[group.type]} photos...`; const learnedMessage = textChanged && photoChanged ? `Pixie learned from your ${sectionLabel[group.type]} and photos.` : `Pixie learned from your ${sectionLabel[group.type]} photos.`; triggerPhotoIntelligence( group.photos, group.type, profileKey, commonName, scientificName, learningMessage, learnedMessage, ); } } } } // Bounded silent retry: up to 5 attempts with LINEAR backoff (2s, 4s, 6s, // 8s, 10s — BACKOFF_MS * n). Runs in the background — never blocks the // user. On final failure it stops silently (fingerprint not stored, so a // re-tap retries cleanly). // NOTE (Bekky, 2026-08-20): Save-and-Refresh now refreshes CARE ONLY — it // calls generateCareGuidance (not enrichPlantProfile), so it never touches // the basics (bio/propagation are static species facts). const MAX_ATTEMPTS = 5; const BACKOFF_MS = 2000; const attempt = (n: number): Promise => import('../services/plantEnrichment').then(mod => mod.generateCareGuidance(commonName, scientificName, { organicFirst: carePreferences?.organicFirst, location: approximateLocationContext || null, contextPacket, containerContext, }).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) { // Always update the plant — works even if the user navigated away. // Care-only: update careSchedule + fingerprints, never the basics. const currentSchedule = selectedPlantIdRef.current === plantId ? savedPlants?.find(p => p.id === plantId)?.careSchedule : undefined; // Preserve the stable year-round Inspect guidance (whatToLookFor + // pest chips) across the refresh — it's fetched once, never re-fetched // (Bekky, 2026-08-26). Without this, a refresh wipes the pest chips. const merged = preserveStableInspectGuidance(currentSchedule, result); if (!merged) return; // SHARED CONTAINER (Bekky, 2026-09-04): write the SAME schedule to every // roommate so the whole container shares one care plan. Each member // preserves its own stable Inspect guidance + fingerprints. containerMemberIds.forEach(memberId => { const memberCurrent = savedPlants?.find(p => p.id === memberId)?.careSchedule; const memberMerged = preserveStableInspectGuidance(memberCurrent, result); if (!memberMerged) return; onUpdatePlant(memberId, { careSchedule: memberMerged, contextFingerprint: fingerprint, contextPhotoFingerprint: photoFingerprint, contextSectionFingerprints: sectionTextFingerprints, contextSectionPhotoFingerprints: sectionPhotoFingerprints, }); }); // Only show the success modal if the user is STILL viewing this plant. if (selectedPlantIdRef.current === plantId) { setCuttingConfirmation({ title: 'Pixie setup complete ✨', message: `${displayName}'s setup is all done — Pixie has everything it needs. Care guidance will stay up to date on its own.`, }); } // Mark every section that was "learning" as "learned" so the status // flips from "Pixie is learning from your setup..." to "Pixie learned // from your setup." (Bekky, 2026-08-19.) Environment + placement fold // into one "environment and placement data" message. if (envOrPlacementChanged) { const placementTouched = placementChanged.textChanged || placementChanged.photoChanged; const envTouched = envChanged.textChanged || envChanged.photoChanged; const envPhotos = envChanged.photoChanged || placementChanged.photoChanged; const what = placementTouched ? (envTouched ? (envPhotos ? 'your environment and placement data and photos' : 'your environment and placement data') : (envPhotos ? 'your placement data and photos' : 'your placement data')) : (envPhotos ? 'your environment data and photos' : 'your environment data'); showIntelligenceMessage(`Pixie learned from ${what}.`, 'complete', 'environment'); setSectionMessages(current => ({ ...current, placement: undefined })); } if (setupChanged.textChanged || setupChanged.photoChanged) { const what = setupChanged.textChanged && setupChanged.photoChanged ? 'your setup and photos' : setupChanged.textChanged ? 'your setup' : 'your setup photos'; showIntelligenceMessage(`Pixie learned from ${what}.`, 'complete', 'setup'); } } // On final failure (all retries exhausted): show a gentle error if the // user is STILL viewing this plant (L18) — the fingerprint wasn't stored, // so a re-tap retries cleanly. NOTE: must be in an ELSE — the failure // modal used to run even on SUCCESS, overwriting the success modal // (Bekky saw care guidance update but got "couldn't refresh"). if (!result && selectedPlantIdRef.current === plantId) { setCuttingConfirmation({ title: "Pixie couldn't refresh", message: `${displayName}'s guidance couldn't be updated right now. Try again in a moment — nothing is lost.`, actionText: 'Try again', onAction: () => refreshPlantIntelligence({ force: true }), }); } }).catch(() => { // Silent — never block the user. }).finally(() => setRefreshIntelligenceLoading(false)); }; const closePlantDetail = () => { // The garden pager remounts when the detail early-return unmounts it; allow // it to snap back to the current section on the next mount. gardenPagerInitialSnapRef.current = false; if (activeForm === 'setup' || activeForm === 'plant' || activeForm === 'space' || activeForm === 'event') { setActiveForm(null); setEditingPlantId(null); setEditingSpaceId(null); setEditingSetupPlantId(null); setResumePlantFormAfterSpace(false); setPlantEventForm(makeEmptyPlantEventForm()); pendingDetailFieldTargetRef.current = null; clearDetailTargetHighlight(); detailScrollRef.current?.scrollTo({ y: 0, animated: true }); return; } setSelectedPlant(null); setViewingWishlistItem(null); if (plantReturnDetail?.kind === 'space' && rooms.some(room => room.id === plantReturnDetail.id)) { setGardenSection('spaces'); setGardenDetail(plantReturnDetail); setPlantReturnDetail(null); return; } // Return to the section the detail was opened from. A wishlist detail must // go back to the Wishlist gallery, not leap to the Plants tab (Bekky, 2026-08-25). setGardenSection(viewingWishlistRef.current ? 'wishlist' : 'plants'); viewingWishlistRef.current = false; setGardenDetail(null); }; useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { if (viewingExcursionMap) { // Excursion map modal → close it (Bekky, 2026-09-03). setViewingExcursionMap(null); return true; } if (viewingFieldEntry) { // Field entry detail → back to the album (Bekky, 2026-09-02). setViewingFieldEntry(null); setLightboxIndex(null); return true; } if (viewingFieldAlbum) { // Field album detail → back to the Field Diary (Bekky, 2026-09-02). setViewingFieldAlbum(null); setViewingFieldEntry(null); setLightboxIndex(null); return true; } if (selectedInvestigation) { // Case detail overlays the garden — back closes it and returns to Cases. // The pager remounts on the next render, so allow it to snap back to the // current section. gardenPagerInitialSnapRef.current = false; setSelectedInvestigation(null); return true; } if (pendingDelete) { cancelPendingDelete(); return true; } if (pendingEventDelete) { setPendingEventDelete(null); return true; } if (openGardenDropdown) { setOpenGardenDropdown(null); return true; } if (selectedPlant) { gardenPagerInitialSnapRef.current = false; closePlantDetail(); return true; } if (gardenDetail?.kind === 'space') { gardenPagerInitialSnapRef.current = false; closeSpaceDetail(); return true; } if (activeForm) { closeGardenForms(); return true; } const previousGardenSection = gardenSectionHistoryRef.current.pop(); if (previousGardenSection) { setGardenSection(previousGardenSection); setGardenSearch(''); setOpenGardenDropdown(null); return true; } return false; }); return () => subscription.remove(); }, [activeForm, gardenDetail, openGardenDropdown, pendingDelete, pendingEventDelete, selectedPlant, selectedInvestigation, viewingFieldAlbum, viewingFieldEntry]); const renderGardenPlantTile = (item: GardenItem) => { const plantName = getPlantItemName(item); const plantImage = getPlantItemLaunchImage(item); const itemKey = getPlantItemId(item); const multiDeleteActive = multiDeleteKeys !== null; const tileSelected = multiDeleteKeys?.has(itemKey) ?? false; const openPlant = () => { if (plantLongPressSuppressedRef.current) { plantLongPressSuppressedRef.current = false; return; } if (multiDeleteActive) { toggleMultiDeleteKey(itemKey); return; } openPlantDetail(item.kind === 'saved' ? { kind: 'saved', plant: item.plant } : { kind: 'sample', plant: item.plant }); }; return ( { if (multiDeleteActive) { toggleMultiDeleteKey(itemKey); return; } enterMultiDelete(item); plantLongPressSuppressedRef.current = true; setTimeout(() => { plantLongPressSuppressedRef.current = false; }, 450); }} accessibilityRole="button" accessibilityLabel={item.kind === 'saved' ? `Open ${plantName} plant card` : item.plant.label} > {plantImage.source ? ( ) : ( Plant )} {plantName} {item.kind === 'saved' && item.plant.plantType === 'cutting' ? ( ✂️ ) : null} {item.kind === 'saved' && item.plant.scanMode === 'seed' && item.plant.growthStage === 'seedling' ? ( 🌱 ) : null} {multiDeleteActive ? ( {tileSelected ? : } ) : null} ); }; const openWishlistDetail = (item: WishlistItem) => { const now = new Date().toISOString(); const commonName = cleanPlantDisplayName(item.commonName || 'Wishlist plant'); const scientificName = item.scientificName ? cleanScientificName(item.scientificName) : item.commonName; // Build a rich detail profile (Bekky, 2026-08-25): carry the wishlist's scan // data so Identity/About/Propagation/Wikipedia populate, without care/setup chrome. const detail: SavedPlantProfile = { id: `wishlist_view_${Date.now()}`, photoUri: item.imageUri, image: item.imageUri, commonName, scientificName, confidence: item.confidence, alternatives: [], scanResultId: item.scanResultId, plantType: item.plantType || 'unsure', locationContext: 'unsure', notes: item.notes, careSummary: 'No care plan generated from this wishlist item yet.', toxicityWarning: 'Safety and toxicity were not checked.', createdAt: now, updatedAt: now, source: 'wishlist', scanMode: item.scanMode === 'fungus' ? 'fungus' : undefined, kingdom: item.kingdom, edibility: item.edibility, cultivable: item.cultivable, substrate: item.substrate, identification: item.scientificName || item.commonNames?.length || item.taxonomy || item.description || item.sourceUrl || item.referenceUrl ? { commonName: item.commonName, scientificName: item.scientificName, confidence: item.confidence, suggestions: (item.commonNames || []).slice(0, 4).map(name => ({ name })), description: item.description, sourceUrl: item.sourceUrl, referenceUrl: item.referenceUrl, referenceTitle: item.referenceTitle, referenceSource: item.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: item.propagation ? { canPropagate: item.propagation.canPropagate, methods: (item.propagation.methods || []).map(m => ({ method: (m.method as import('../types/propagation').RootingMethod) || 'water', difficulty: (m.difficulty as import('../types/propagation').PropagationDifficulty) || 'moderate', instructions: m.instructions || [], timeframe: m.timeframe || '', successRate: (m.successRate as import('../types/propagation').SuccessRate) || 'medium', risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (item.propagation.notRecommended || []).map(nr => ({ method: (nr.method as import('../types/propagation').RootingMethod) || 'water', reason: nr.reason || '', })), generalNote: item.propagation.generalNote, } : undefined, }; setActiveForm(null); setGardenDetail(null); viewingWishlistRef.current = true; setViewingWishlistItem(item); setSelectedPlant({ kind: 'saved', plant: detail }); }; // Add a cutting from a wishlist item (moved into the detail view, 2026-08-25). const addWishlistCutting = (item: WishlistItem) => { const now = new Date().toISOString(); const cuttingCommonName = cleanPlantDisplayName(item.commonName || 'Cutting'); onSavePlant({ id: `wishlist_cutting_${Date.now()}`, photoUri: item.imageUri, image: item.imageUri, commonName: cuttingCommonName, scientificName: item.scientificName || item.commonName, confidence: item.confidence, alternatives: [], scanResultId: item.scanResultId, plantType: 'cutting', locationContext: 'unsure', notes: '✂️ From wishlist — Propagation project', careSummary: 'No care plan generated from this wishlist item yet.', toxicityWarning: 'Safety and toxicity were not checked.', createdAt: now, updatedAt: now, source: 'wishlist', identification: item.scientificName || item.description || item.sourceUrl || item.referenceUrl ? { commonName: item.commonName, scientificName: item.scientificName, confidence: item.confidence, suggestions: (item.commonNames || []).slice(0, 4).map(name => ({ name })), description: item.description, sourceUrl: item.sourceUrl, referenceUrl: item.referenceUrl, referenceTitle: item.referenceTitle, referenceSource: item.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: item.propagation ? { canPropagate: item.propagation.canPropagate, methods: (item.propagation.methods || []).map(m => ({ method: (m.method as import('../types/propagation').RootingMethod) || 'water', difficulty: (m.difficulty as import('../types/propagation').PropagationDifficulty) || 'moderate', instructions: m.instructions || [], timeframe: m.timeframe || '', successRate: (m.successRate as import('../types/propagation').SuccessRate) || 'medium', risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (item.propagation.notRecommended || []).map(nr => ({ method: (nr.method as import('../types/propagation').RootingMethod) || 'water', reason: nr.reason || '', })), generalNote: item.propagation.generalNote, } : undefined, }); onRemoveWishlistItem(item.id); setSelectedPlant(null); viewingWishlistRef.current = false; setViewingWishlistItem(null); }; // "Start Growing" from a wishlist seed item (Phase 3 continuation, 2026-09-08): // lets the user skip the scan and start growing a saved seed directly. Adds it // to the Garden as a seed plant (growthStage 'seedling'), carrying the seed // profile through. Only shown for seed items (scanMode === 'seed'). const startGrowingFromWishlist = (item: WishlistItem) => { const now = new Date().toISOString(); const commonName = cleanPlantDisplayName(item.commonName || 'Seed'); onSavePlant({ id: `wishlist_seed_${Date.now()}`, photoUri: item.imageUri, image: item.imageUri, commonName, scientificName: item.scientificName || item.commonName, confidence: item.confidence, alternatives: [], scanResultId: item.scanResultId, plantType: 'garden_crop', growthStage: 'seedling', locationContext: 'unsure', notes: '🌱 From wishlist — Start growing', careSummary: 'Seed planted — will need setup + care guidance.', toxicityWarning: 'Safety and toxicity were not checked.', setupCompleted: false, createdAt: now, updatedAt: now, source: 'wishlist', scanMode: 'seed', seed: item.seed, seedKind: item.seedKind, identification: item.scientificName || item.description || item.sourceUrl || item.referenceUrl ? { commonName: item.commonName, scientificName: item.scientificName, confidence: item.confidence, suggestions: (item.commonNames || []).slice(0, 4).map(name => ({ name })), description: item.description, sourceUrl: item.sourceUrl, referenceUrl: item.referenceUrl, referenceTitle: item.referenceTitle, referenceSource: item.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: item.propagation ? { canPropagate: item.propagation.canPropagate, methods: (item.propagation.methods || []).map(m => ({ method: (m.method as import('../types/propagation').RootingMethod) || 'water', difficulty: (m.difficulty as import('../types/propagation').PropagationDifficulty) || 'moderate', instructions: m.instructions || [], timeframe: m.timeframe || '', successRate: (m.successRate as import('../types/propagation').SuccessRate) || 'medium', risks: m.risks || '', warning: m.warning ?? null, })), notRecommended: (item.propagation.notRecommended || []).map(nr => ({ method: (nr.method as import('../types/propagation').RootingMethod) || 'water', reason: nr.reason || '', })), generalNote: item.propagation.generalNote, } : undefined, }); onRemoveWishlistItem(item.id); setSelectedPlant(null); viewingWishlistRef.current = false; setViewingWishlistItem(null); }; // Open a Field Diary album detail view (Bekky, 2026-08-25). const openFieldAlbumDetail = (album: FieldAlbum) => { setViewingFieldAlbum(album); setViewingFieldEntry(null); setLightboxIndex(null); }; // "Add to Garden" from a Field Diary entry (Phase 3 continuation, 2026-09-08): // lets the user skip the scan and add a field find to the Garden directly. const addFieldEntryToGarden = (entry: import('../types/garden').FieldDiaryEntry) => { const now = new Date().toISOString(); const commonName = cleanPlantDisplayName(entry.commonName || 'Field find'); onSavePlant({ id: `field_plant_${Date.now()}`, photoUri: entry.coverPhotoUri || entry.photos?.[0]?.uri, image: entry.coverPhotoUri || entry.photos?.[0]?.uri, commonName, scientificName: entry.scientificName || entry.commonName, confidence: entry.confidence, alternatives: [], scanResultId: entry.scanResultId, plantType: entry.plantType || 'unsure', locationContext: 'unsure', notes: entry.description || '📍 From Field Diary', careSummary: 'No care plan generated from this field find yet.', toxicityWarning: 'Safety and toxicity were not checked.', createdAt: now, updatedAt: now, source: 'field_diary', scanMode: entry.scanMode, kingdom: entry.kingdom, edibility: entry.edibility, cultivable: entry.cultivable, substrate: entry.substrate, identification: entry.scientificName || entry.commonNames?.length || entry.taxonomy || entry.description || entry.sourceUrl || entry.referenceUrl ? { commonName: entry.commonName, scientificName: entry.scientificName, confidence: entry.confidence, suggestions: (entry.commonNames || []).slice(0, 4).map(name => ({ name })), description: entry.description, sourceUrl: entry.sourceUrl, referenceUrl: entry.referenceUrl, referenceTitle: entry.referenceTitle, referenceSource: entry.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: undefined, }); setViewingFieldEntry(null); setLightboxIndex(null); }; // "Start Growing" from a Field Diary seed entry (Phase 3 continuation, // 2026-09-08): lets the user skip the scan and start growing a saved seed // directly. Adds it to the Garden as a seed plant (growthStage 'seedling'). const startGrowingFromFieldEntry = (entry: import('../types/garden').FieldDiaryEntry) => { const now = new Date().toISOString(); const commonName = cleanPlantDisplayName(entry.commonName || 'Seed'); onSavePlant({ id: `field_seed_${Date.now()}`, photoUri: entry.coverPhotoUri || entry.photos?.[0]?.uri, image: entry.coverPhotoUri || entry.photos?.[0]?.uri, commonName, scientificName: entry.scientificName || entry.commonName, confidence: entry.confidence, alternatives: [], scanResultId: entry.scanResultId, plantType: 'garden_crop', growthStage: 'seedling', locationContext: 'unsure', notes: '🌱 From Field Diary — Start growing', careSummary: 'Seed planted — will need setup + care guidance.', toxicityWarning: 'Safety and toxicity were not checked.', setupCompleted: false, createdAt: now, updatedAt: now, source: 'field_diary', scanMode: 'seed', seed: entry.seed, seedKind: entry.seedKind, identification: entry.scientificName || entry.commonNames?.length || entry.taxonomy || entry.description || entry.sourceUrl || entry.referenceUrl ? { commonName: entry.commonName, scientificName: entry.scientificName, confidence: entry.confidence, suggestions: (entry.commonNames || []).slice(0, 4).map(name => ({ name })), description: entry.description, sourceUrl: entry.sourceUrl, referenceUrl: entry.referenceUrl, referenceTitle: entry.referenceTitle, referenceSource: entry.referenceSource, } : undefined, enrichmentData: undefined, propagationInfo: undefined, }); setViewingFieldEntry(null); setLightboxIndex(null); }; // Close the Field Diary album detail view (back to the gallery). const closeFieldAlbumDetail = () => { setViewingFieldAlbum(null); setViewingFieldEntry(null); setLightboxIndex(null); // The pager unmounts while the album detail early-return shows, and remounts // at offset 0 (Plants) on back. Reset the snap ref so it snaps back to the // Field section (Bekky, 2026-09-03: back showed Field tab but Plants page). gardenPagerInitialSnapRef.current = false; }; // ---- Take a Walk / Take a Ride (Bekky, 2026-09-03) ---- // Load any active excursion on mount (crash recovery). useEffect(() => { let mounted = true; getActiveExcursion().then((state) => { if (mounted && state) { setActiveExcursion(state); setExcursionElapsed(Math.max(0, Math.round((Date.now() - Date.parse(state.startedAt)) / 1000))); } }); return () => { mounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Live timer + distance for the running excursion. The button shows the live // mileage, so every tick we ALSO re-read the persisted excursion state (the // background task accumulates distance there) and update the UI. Otherwise // the timer ticks but distance stays 0 (Bekky, 2026-09-03 on-device bug). useEffect(() => { if (!activeExcursion) return; const id = setInterval(() => { const elapsed = Math.max(0, Math.round((Date.now() - Date.parse(activeExcursion.startedAt)) / 1000)); setExcursionElapsed(elapsed); // Refresh live distance from the persisted state (best-effort). getActiveExcursion().then((state) => { if (state && state.excursionId === activeExcursion.excursionId) { setActiveExcursion(state); } }).catch(() => {}); }, 1000); return () => clearInterval(id); }, [activeExcursion]); // Start an excursion. Shows GPS/battery consent first (Bekky: users agree to // the battery drain + grant permission; location used ONLY for this feature). const handleStartExcursion = (mode: import('../types/garden').ExcursionMode) => { if (activeExcursion) return; // mutual exclusivity — only one at a time setExcursionConsent({ mode }); }; const confirmStartExcursion = async (mode: import('../types/garden').ExcursionMode) => { setExcursionConsent(null); try { const state = await startExcursion(mode); setActiveExcursion(state); setExcursionElapsed(0); } catch (e) { const msg = e instanceof Error ? e.message : ''; const denied = msg === 'location-denied'; const permanent = msg === 'location-denied-permanent'; if (denied || permanent) { // Sweet-and-kind response: the feature can't work without location. setExcursionConsent(null); setExcursionExitConfirm(false); if (permanent) { // Can't re-ask in-app — offer a shortcut to the exact system settings. Alert.alert( 'Pixie needs your location 🌿', 'I can\'t track your walk without location permission — that\'s the only way I know where you went and how far. It looks like location is turned off for PixieSprout in your system settings. Tap below to open them — it\'s only used for this feature, never otherwise. 💚', [ { text: 'Open settings', onPress: () => { Linking.openSettings().catch(() => {}); } }, { text: 'Not now', style: 'cancel' }, ] ); } else { Alert.alert( 'Pixie needs your location 🌿', 'I can\'t track your walk without location permission — that\'s the only way I know where you went and how far. No worries at all if you\'d rather not; you can still save plants to your Field Diary the usual way. 💚' ); } } else { Alert.alert('Couldn\'t start', e instanceof Error ? e.message : 'Something went wrong.'); } } }; // Confirm-to-exit: tap the active button again. const handleStopExcursion = () => { if (!activeExcursion) return; setExcursionExitConfirm(true); }; const confirmStopExcursion = async () => { setExcursionExitConfirm(false); try { const album = await stopExcursion(); setActiveExcursion(null); setExcursionElapsed(0); // Add the excursion album to the field journal (Bekky: BOTH map + journal). onSaveFieldDiaryEntryAlbum(album); // Open the new album so the user sees the map immediately. setViewingFieldAlbum(album); } catch (e) { Alert.alert('Couldn\'t end', e instanceof Error ? e.message : 'Something went wrong.'); } }; // Open the interactive map for an excursion album. const openExcursionMap = (album: FieldAlbum) => { setViewingExcursionMap(album); }; const closeExcursionMap = () => { setViewingExcursionMap(null); }; const renderWishlistItemCard = (item: WishlistItem) => { const wishlistDeleteActive = wishlistDeleteKeys !== null; const tileSelected = wishlistDeleteKeys?.has(item.id) ?? false; const openWishlist = () => { if (wishlistDeleteActive) { toggleWishlistDeleteKey(item.id); return; } openWishlistDetail(item); }; return ( { if (wishlistDeleteActive) { toggleWishlistDeleteKey(item.id); return; } enterWishlistDelete(item); }} accessibilityRole="button" accessibilityLabel={`Open ${cleanPlantDisplayName(item.commonName)} detail`} > {item.imageUri ? ( ) : ( {item.kingdom === 'mushroom' ? '🍄' : item.kingdom === 'bracket_fungus' ? '🪵' : '🌿'} )} {wishlistDeleteActive ? ( {tileSelected ? : } ) : null} {cleanPlantDisplayName(item.commonName)} ); }; // Field Diary album tile (Bekky, 2026-08-25): one album per broad place. // Shows the album's cover (first entry's cover photo) + place label + find count. const renderFieldAlbumCard = (album: FieldAlbum) => { const coverUri = album.entries[0]?.coverPhotoUri || album.entries[0]?.photos?.[0]?.uri; const firstDate = album.entries[0]?.createdAt; const fieldDeleteActive = fieldDeleteKeys !== null; const tileSelected = fieldDeleteKeys?.has(album.id) ?? false; const openAlbum = () => { if (fieldDeleteActive) { toggleFieldDeleteKey(album.id); return; } openFieldAlbumDetail(album); }; return ( { if (fieldDeleteActive) { toggleFieldDeleteKey(album.id); return; } enterFieldDelete(album); }} > {album.route && album.route.length >= 2 ? ( ) : coverUri ? ( ) : ( 🗺️ )} {album.placeLabel} {album.entries.length} find{album.entries.length !== 1 ? 's' : ''} {firstDate ? ` · ${new Date(firstDate).toLocaleDateString()}` : ''} ); }; // ── Field Diary album + entry detail (Bekky, 2026-08-25) ────────────────── if (viewingFieldAlbum) { const album = viewingFieldAlbum; const entry = viewingFieldEntry; const onClose = () => setViewingFieldAlbum(null); return ( { if (viewingFieldEntry) { setViewingFieldEntry(null); setLightboxIndex(null); } else onClose(); })}> {viewingFieldEntry ? '← Back to album' : '← Back to Field Diary'} {!viewingFieldEntry ? ( <> {album.placeLabel} {album.entries.length} find{album.entries.length !== 1 ? 's' : ''} {/* Google Maps link (Bekky, 2026-09-02, refined 2026-09-08): instead of picking ONE entry's GPS (which one?), compute the CENTROID of all entries with precise GPS + a radius (max distance from the centroid). Opens Google Maps at the centroid. If only one entry has GPS, it's just that point. */} {(() => { const pts = album.entries .map(e => e.location) .filter((l): l is NonNullable => typeof l?.latitude === 'number' && typeof l.longitude === 'number') .map(l => ({ lat: l.latitude!, lng: l.longitude! })); if (pts.length === 0) return null; // Centroid = average of all points. const lat = pts.reduce((s, p) => s + p.lat, 0) / pts.length; const lng = pts.reduce((s, p) => s + p.lng, 0) / pts.length; // Radius = max great-circle distance (meters) from centroid. const R = 6371000; const toRad = (d: number) => d * Math.PI / 180; let maxM = 0; for (const p of pts) { const dLat = toRad(p.lat - lat); const dLng = toRad(p.lng - lng); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat)) * Math.cos(toRad(p.lat)) * Math.sin(dLng / 2) ** 2; const dist = 2 * R * Math.asin(Math.sqrt(a)); if (dist > maxM) maxM = dist; } const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${lat.toFixed(6)},${lng.toFixed(6)}`; const radiusLabel = maxM >= 1000 ? ` (${(maxM / 1000).toFixed(1)} km spread)` : maxM > 0 ? ` (${Math.round(maxM)} m spread)` : ''; return ( { try { await Linking.openURL(mapsUrl); } catch { Alert.alert('Maps unavailable', 'Google Maps could not be opened right now.'); } })} > 📍 Open in Google Maps{radiusLabel} ); })()} {/* Walk finds as GARDEN-RICH photo tiles (Bekky, 2026-09-02): each find is a photo tile + name, like the garden grid. Tap a tile to open the full entry (photos + rich info). */} {/* Map tile FIRST (Bekky, 2026-09-03): for excursion albums, the first tile is the interactive map. Tap to open + interact. */} {album.excursionMode && album.route && album.route.length >= 2 ? ( openExcursionMap(album))} > 🗺️ Map {formatExcursionDistance(album.distanceMeters ?? 0)} · {formatExcursionDuration(album.durationSec ?? 0)} ) : null} {album.entries.map(entry => { const cover = entry.coverPhotoUri || entry.photos?.[0]?.uri; return ( setViewingFieldEntry(entry))} > {cover ? ( ) : ( 🌿 )} {cleanPlantDisplayName(entry.commonName)} ); })} ) : ( <> {cleanPlantDisplayName(viewingFieldEntry!.commonName)} {viewingFieldEntry!.scientificName ? {cleanScientificName(viewingFieldEntry!.scientificName)} : null} {new Date(viewingFieldEntry!.createdAt).toLocaleDateString()} {viewingFieldEntry!.location?.placeLabel ? ` · ${viewingFieldEntry!.location.placeLabel}` : ''} {/* Where I'm from (Bekky, 2026-09-08): precise GPS for THIS entry, with an Open in Google Maps link. Precise only. */} {(() => { const loc = viewingFieldEntry!.location; const lat = loc?.latitude; const lng = loc?.longitude; if (typeof lat !== 'number' || typeof lng !== 'number') return null; return ( { try { await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`); } catch { Alert.alert('Maps unavailable', 'Google Maps could not be opened right now.'); } })} > 📍 Open in Google Maps ); })()} {/* Structured, readable detail (Bekky, 2026-09-02): the entry detail should read like the wishlist detail — clean cards on the cream background, not raw text floating on the botanical art. */}
{viewingFieldEntry!.description ? ( {viewingFieldEntry!.description} ) : ( No description saved for this find. )} {viewingFieldEntry!.commonNames && viewingFieldEntry!.commonNames.length ? ( Also known as: {viewingFieldEntry!.commonNames.slice(0, 4).join(', ')} ) : null} {(viewingFieldEntry!.plantType || viewingFieldEntry!.petSafety || (viewingFieldEntry!.edibility && viewingFieldEntry!.scanMode === 'fungus')) ? (
{viewingFieldEntry!.plantType ? ( 🌿 Type: {viewingFieldEntry!.plantType} ) : null} {viewingFieldEntry!.petSafety ? ( 🐾 {viewingFieldEntry!.petSafety === 'safe' ? 'Pet safe' : viewingFieldEntry!.petSafety === 'caution' ? 'Pet caution' : viewingFieldEntry!.petSafety === 'toxic' ? 'Toxic to pets' : 'Pet safety unknown'} ) : null} {viewingFieldEntry!.edibility && viewingFieldEntry!.scanMode === 'fungus' ? ( 🍄 {viewingFieldEntry!.edibility === 'edible' ? 'Edible (unverified)' : viewingFieldEntry!.edibility === 'poisonous' ? 'Poisonous' : 'Edibility unknown'} ) : null} ) : null} {viewingFieldEntry!.referenceUrl ? (
{ try { await Linking.openURL(viewingFieldEntry!.referenceUrl!); } catch { Alert.alert('Link unavailable', 'That page could not be opened right now.'); } })}> 🔗 {viewingFieldEntry!.referenceTitle || 'Read more about this plant'} ) : null} {viewingFieldEntry!.photos && viewingFieldEntry!.photos.length ? (
{viewingFieldEntry!.photos.map((p, i) => ( setLightboxIndex(i)}> ))} ) : null} {/* Field find → Garden shortcuts (Phase 3 continuation, 2026-09-08): let the user skip the scan and add a field find to the Garden. Seed finds get BOTH "Add to Garden" AND "Start Growing". */} addFieldEntryToGarden(viewingFieldEntry!))}> Add to Garden {viewingFieldEntry!.scanMode === 'seed' ? ( startGrowingFromFieldEntry(viewingFieldEntry!))}> 🌿 Start Growing ) : null} )} {lightboxIndex !== null && (viewingFieldEntry || album.entries.length) ? (() => { const photos = viewingFieldEntry ? viewingFieldEntry.photos : (album.entries[0]?.photos || []); const idx = Math.min(lightboxIndex, photos.length - 1); const current = photos[idx]; if (!current) return null; return ( setLightboxIndex(null)}> setLightboxIndex(null)}> {photos.length > 1 ? ( setLightboxIndex((idx - 1 + photos.length) % photos.length)}> setLightboxIndex((idx + 1) % photos.length)}> ) : null} ); })() : null} {/* Excursion map modal — MUST be in this album-detail branch too, or tapping the map tile sets state but the modal isn't in the tree (Bekky, 2026-09-03 on-device: map only appeared after back). */} {viewingExcursionMap?.placeLabel || 'Trip map'} {viewingExcursionMap ? ( ) : null} ); } // Investigation detail view if (selectedInvestigation) { const inv = selectedInvestigation; const plantName = inv.plantId ? (savedPlants?.find(p => p.id === inv.plantId)?.name || savedPlants?.find(p => p.id === inv.plantId)?.commonName) : null; const statusColors: Record = { open: '#FF9800', monitoring: '#2196F3', resolved: '#4CAF50', archived: '#9E9E9E', }; return ( { gardenPagerInitialSnapRef.current = false; setSelectedInvestigation(null); })} > ← Back Investigation {/* Title & Status */} {inv.title} {inv.status} {plantName ? ( 🌿 {plantName} ) : ( No plant linked )} Created {new Date(inv.createdAt).toLocaleDateString()} at {new Date(inv.createdAt).toLocaleTimeString()} {/* Points — each scan/visit in this case */} {(inv.points && inv.points.length > 0) ? inv.points.map((point, pointIdx) => ( {/* Visit header — title + date, chevron toggle on the right (collapsible, persists per-case via collapsedPoints) */} toggleVisitCollapsed(point.pointId))} > {inv.points.length > 1 ? `Visit ${pointIdx + 1}` : 'Visit'} {new Date(point.createdAt).toLocaleDateString()} at {new Date(point.createdAt).toLocaleTimeString()} {!isVisitCollapsed(point.pointId) && (<> {/* Photos + Symptoms side by side: thumbnail left, fine-print bullet list right */} {(point.photos.length > 0 || point.symptoms.length > 0) && ( {point.photos.length > 0 && ( {point.photos.map((photo) => ( setPlacementViewerUri(photo.uri))} > {photo.category.replace(/_/g, ' ')} ))} )} {point.symptoms.length > 0 && ( {point.symptoms.map((symptom, i) => ( {friendlySymptomLabel(symptom)} ))} )} )} {/* Notes */} {point.notes ? ( {point.notes} ) : null} {/* Pixie's First Look */} {point.firstLook && ( {point.firstLook.summary} {point.firstLook.noticed.length > 0 && ( What Pixie noticed {point.firstLook.noticed.map((item, i) => ( {item} ))} )} {point.firstLook.possibleCauses.length > 0 && ( Possible causes {point.firstLook.possibleCauses.map((item, i) => ( {item} ))} )} {point.firstLook.suggestedChecks.length > 0 && ( Things to check next {point.firstLook.suggestedChecks.map((item, i) => ( {item} ))} )} Confidence:{' '} {point.firstLook.confidence === 'high' ? 'Pixie feels fairly sure about this' : point.firstLook.confidence === 'medium' ? 'Pixie has some ideas but is not certain' : "Pixie is sharing her best guess — she'd need more info to be sure"} )} )} )) : ( /* Legacy fallback — no points array (shouldn't happen after backfill) */ <> {inv.photos.length > 0 && (
{inv.photos.map((photo) => ( setPlacementViewerUri(photo.uri))} > {photo.category.replace(/_/g, ' ')} ))} )} {inv.symptoms.length > 0 && (
{inv.symptoms.map(symptom => ( {friendlySymptomLabel(symptom)} ))} )} {inv.notes ? (
{inv.notes} ) : null} {inv.firstLook && (
{inv.firstLook.summary} {inv.firstLook.noticed.length > 0 && ( What Pixie noticed {inv.firstLook.noticed.map((item, i) => ( {item} ))} )} {inv.firstLook.possibleCauses.length > 0 && ( Possible causes {inv.firstLook.possibleCauses.map((item, i) => ( {item} ))} )} {inv.firstLook.suggestedChecks.length > 0 && ( Things to check next {inv.firstLook.suggestedChecks.map((item, i) => ( {item} ))} )} Confidence:{' '} {inv.firstLook.confidence === 'high' ? 'Pixie feels fairly sure about this' : inv.firstLook.confidence === 'medium' ? 'Pixie has some ideas but is not certain' : "Pixie is sharing her best guess — she'd need more info to be sure"} )} )} {/* Treatment Plan — what the doctor prescribed */} {inv.treatmentPlan && inv.treatmentPlan.steps.length > 0 && (
{inv.treatmentPlan.generalNote ? ( {inv.treatmentPlan.generalNote} ) : null} {inv.treatmentPlan.steps.map((step) => { // OVERDUE DETECTION (Bekky, 2026-09-02): a Treat step's real // due date is the case's createdAt + its offset. A step // prescribed "today" weeks ago is NOT still "today" — it's // overdue. Only a step the user has actually recorded as done // (via the case-treatment modal) is no longer pending. const exec = (inv.stepExecutions || []).find(e => e.stepId === step.stepId); const isDone = exec?.status === 'done'; const isSkipped = exec?.status === 'skipped'; const created = new Date(inv.createdAt); const createdValid = !Number.isNaN(created.getTime()); const stepDue = createdValid ? new Date(created.getTime() + (step.dueOffsetDays || 0) * 86400000) : null; const today = new Date(); today.setHours(0, 0, 0, 0); const isOverdue = step.kind !== 'recheck' && !isDone && stepDue !== null && stepDue.getTime() < today.getTime(); return ( {step.kind === 'recheck' ? '🔁 Recheck' : '🩺 Treat'} — {step.action} {isDone ? '✓ done' : isSkipped ? '⏭ skipped' : isOverdue ? 'overdue' : step.kind === 'recheck' ? `in ${inv.treatmentPlan?.recheckDays ?? 7} days` : step.dueOffsetDays === 0 ? 'today' : `in ${step.dueOffsetDays} days`} {isOverdue ? ( This step was due a while ago and hasn't been logged. Did you do it? ) : null} {step.instructions.length > 0 && ( {step.instructions.map((item, i) => ( {item} ))} )} {step.materials && step.materials.length > 0 && ( 🧰 Materials {step.materials.join(', ')} )} {step.safetyWarnings.length > 0 && ( ⚠️ {step.safetyWarnings.join(' ')} )} ); })} {/* CASE-TREATMENT history (Bekky, 2026-09-02): what's already been logged/done/skipped on this case, so the treatment plan shows progress (the magic-bullet capture visible at a glance). */} {inv.stepExecutions && inv.stepExecutions.length > 0 ? ( Logged on this case {inv.stepExecutions.map((exec, i) => { const step = inv.treatmentPlan?.steps.find(x => x.stepId === exec.stepId); return ( {exec.status === 'done' ? '🩺 Done' : '⏭ Skipped'} {step?.action || 'step'}{exec.usedProduct ? ` — used ${exec.usedProduct}` : ''} {exec.outcome ? ` · ${exec.outcome === 'better' ? 'helping' : exec.outcome}` : ''} ); })} ) : null} {/* CASE-TREATMENT entry (Bekky, 2026-09-02): the same multi-select modal as the Care-tab — log what was actually done/used for the due steps, straight from the case itself. */} {onOpenCaseTreatment ? ( onOpenCaseTreatment(inv), haptic.success)} accessibilityRole="button" accessibilityLabel="Log what I did for this treatment" > 🩺 Log what I did ) : null} )} {/* Progress Updates — the check-in history (progress photos + assessments) */} {inv.progressUpdates && inv.progressUpdates.length > 0 && (
{inv.progressUpdates.map((pu) => ( {pu.betterOrWorse === 'better' ? '🟢 Looking better' : pu.betterOrWorse === 'same' ? '🟡 About the same' : '🔴 Looking worse'} {new Date(pu.createdAt).toLocaleDateString()} at {new Date(pu.createdAt).toLocaleTimeString()} {pu.entryType === 'early' ? ( Early check-in — Pixie looked for signs of a setback ) : null} {pu.photoUri ? ( ) : null} {pu.note ? ( {pu.note} ) : null} {pu.aiAssessment ? ( {pu.aiAssessment.summary} {pu.aiAssessment.nextStep ? ( Next step: {pu.aiAssessment.nextStep.action} ) : null} ) : null} ))} )} {/* Check-in actions — below the guidance: Add Progress Update / Get a Second Look */} {(inv.status === 'open' || inv.status === 'monitoring') && (
{inv.treatmentPlan ? 'Tell Pixie how the treatment is going with a quick photo.' : 'Share a progress photo so Pixie can see how it is going.'} setCheckInTarget({ inv, mode: 'progress' }))} > 📸 Add Progress Update setCheckInTarget({ inv, mode: 'secondLook' }))} > 💭 Get a Second Look )} {/* Timeline */} {inv.timeline.length > 0 && (
{inv.timeline.map(event => ( {event.type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} {event.detail ? ( {event.type === 'symptoms_recorded' ? event.detail.split(', ').map(s => friendlySymptomLabel(s)).join(', ') : event.detail} ) : null} {new Date(event.timestamp).toLocaleString()} ))} )} {/* Resolve — Phase 2: capture what worked / didn't for the next case */} {inv.status === 'open' || inv.status === 'monitoring' ? (
When this issue is resolved, tell Pixie what worked so she remembers it for next time. setResolveOutcomeInv(inv))} > ✓ Mark as Resolved ) : inv.status === 'resolved' && inv.plantId ? ( ✓ This case is resolved. ) : null} {/* Delete this case — clear old cases so new ones can be started */} setPendingInvDelete(inv))} > 🗑 Delete this case p.id === resolveOutcomeInv?.plantId)?.name || savedPlants?.find(p => p.id === resolveOutcomeInv?.plantId)?.commonName || resolveOutcomeInv?.title || '') : (resolveOutcomeInv?.title || '')} onConfirm={async (outcome) => { const inv = resolveOutcomeInv; if (!inv) return; try { if (inv.plantId && (outcome.successful.length > 0 || outcome.unsuccessful.length > 0)) { const now = new Date().toISOString(); for (const desc of outcome.successful) { await addIntervention(inv.plantId, { id: `intervention_${Date.now()}_${Math.round(Math.random() * 10000)}`, description: desc, outcome: 'successful', date: now, relatedEventId: inv.investigationId, }); } for (const desc of outcome.unsuccessful) { await addIntervention(inv.plantId, { id: `intervention_${Date.now()}_${Math.round(Math.random() * 10000)}`, description: desc, outcome: 'unsuccessful', date: now, relatedEventId: inv.investigationId, }); } } await updateInvestigationStatus(inv.investigationId, 'resolved'); getAllInvestigations().then(loaded => setInvestigations(loaded)).catch(() => {}); setResolveOutcomeInv(null); setSelectedInvestigation(current => current && current.investigationId === inv.investigationId ? { ...current, status: 'resolved' as const } : current); setCuttingConfirmation({ title: 'Case resolved!', message: 'Pixie has remembered what worked (and what didn\u2019t) for next time this plant has a problem.' }); } catch { setResolveOutcomeInv(null); setCuttingConfirmation({ title: 'Couldn\u2019t resolve', message: 'Something went wrong saving the outcome. Please try again.' }); } }} onClose={() => setResolveOutcomeInv(null)} /> p.id === checkInTarget.inv.plantId)?.name || savedPlants?.find(p => p.id === checkInTarget.inv.plantId)?.commonName || checkInTarget.inv.title || '') : (checkInTarget?.inv?.title || '')} treatmentPlan={checkInTarget?.inv?.treatmentPlan} referenceIntakeUris={(() => { const inv = checkInTarget?.inv; if (!inv) return []; const latest = inv.points && inv.points.length > 0 ? inv.points[inv.points.length - 1] : null; const photos = latest ? latest.photos : (inv.photos || []); return photos.map(p => p.uri); })()} onCancel={() => setCheckInTarget(null)} onSubmitted={async (update) => { const inv = checkInTarget?.inv; if (!inv) return; try { const updated = await appendProgressUpdate(inv.investigationId, update); if (updated) { setSelectedInvestigation(updated); getAllInvestigations().then(loaded => setInvestigations(loaded)).catch(() => {}); } } catch { // non-fatal — user can retry } setCheckInTarget(null); }} /> { const a = cuttingConfirmation.onAction; setCuttingConfirmation(null); a?.(); } }] : []), { text: 'OK', style: 'default', onPress: () => setCuttingConfirmation(null) }, ]} onRequestClose={() => setCuttingConfirmation(null)} /> setPendingInvDelete(null) }, { text: 'Delete', style: 'destructive', onPress: async () => { const inv = pendingInvDelete; setPendingInvDelete(null); if (!inv) return; try { await deleteInvestigation(inv.investigationId); getAllInvestigations().then(loaded => setInvestigations(loaded)).catch(() => {}); setSelectedInvestigation(null); } catch { setCuttingConfirmation({ title: "Couldn't delete", message: 'Something went wrong removing the case. Please try again.' }); } } }, ]} onRequestClose={() => setPendingInvDelete(null)} /> ); } if (gardenDetail?.kind === 'space') { const room = rooms.find(item => item.id === gardenDetail.id); if (room) { const spaceArtwork = getSpaceArtwork(room.spaceType); const assignedItems = getAssignedGardenItemsForSpace(room.id); const spaceSummary = summarizeSpace(assignedItems); const statusStyle = spaceSummary.statusTone === 'needs' ? s.spaceStatus_needs : spaceSummary.statusTone === 'good' ? s.spaceStatus_good : s.spaceStatus_empty; const isAssigningThisSpace = activeForm === 'assignPlants' && assigningSpaceId === room.id; const hasGardenPlants = orderedGardenItems.length > 0; return (
{/* INTELLIGENT GROUPINGS — visible control ON the space detail card (Bekky, 2026-08-30): people shouldn't have to open Edit Space to find the opt-out. Writes straight to the room (persisted via the rooms save effect). Independent of Water-all. */}
{ const next = room.cohortsEnabled === false; setRooms(cur => cur.map(r => r.id === room.id ? { ...r, cohortsEnabled: next, updatedAt: new Date().toISOString() } : r)); }, haptic.success)} accessibilityRole="switch" accessibilityState={{ checked: room.cohortsEnabled !== false }} > {room.cohortsEnabled === false ? 'Off' : 'On'} Plants are grouped intelligently and cared for together. Turning this off just pauses the groups — nothing is lost, and it restarts whenever you like. {/* WATER-ALL — TOP OF SPACE DETAIL (Bekky, 2026-08-30): the first thing on the page, because anyone who taps it is in a hurry. One tap = "I watered this whole space, today" — anchor write for every plant (zero pending tasks OK; independent of the groupings toggle). Renders for EVERY space, cohorts or not. */} {onSpaceWaterAll ? ( { const spacePlantIds = savedPlants.filter(p => p.spaceId === room.id).map(p => p.id); if (spacePlantIds.length) { onSpaceWaterAll({ spaceId: room.id, plantIds: spacePlantIds, action: 'water' }); } }, haptic.success)} disabled={!savedPlants.some(p => p.spaceId === room.id)} accessibilityRole="button" accessibilityLabel={`Water all plants in ${room.name} — resets the entire space's last watered date to today`} > 💧 Water all in {room.name} Resets the entire space's last watered date to today ) : null} {/* FEED-ALL — Bekky, 2026-08-30: fertilize twin of Water-all. One tap = "I fed this whole space, today" — same anchor-write semantics (manual sweep log; zero pending tasks OK; toggle-independent). */} {onSpaceWaterAll ? ( { const spacePlantIds = savedPlants.filter(p => p.spaceId === room.id).map(p => p.id); if (spacePlantIds.length) { onSpaceWaterAll({ spaceId: room.id, plantIds: spacePlantIds, action: 'fertilize' }); } }, haptic.success)} disabled={!savedPlants.some(p => p.spaceId === room.id)} accessibilityRole="button" accessibilityLabel={`Fertilize all plants in ${room.name} — resets the entire space's last fertilized date to today`} > 🌱 Feed all in {room.name} Resets the entire space's last fertilized date to today ) : null}
toggleSpaceTile(room, 'details'))} accessibilityRole="button" accessibilityState={{ expanded: !isSpaceTileCollapsed(room, 'details') }} > {!isSpaceTileCollapsed(room, 'details') ? (<> {formatSpaceStatusSummary(spaceSummary)} Light: {formatSpaceLight(room)} {room.usesAmbientArtificialLight ? Ambient artificial light: {formatAmbientArtificialLight(room)} : null} Humidity: {formatHumidityProfile(room.humidityProfile)} {isOutdoorSpaceType(room.spaceType) && approximateLocationContext ? Location: {approximateLocationContext} : null} Space Type{formatSpaceType(room.spaceType)} {isOutdoorSpaceType(room.spaceType) ? ( <> Sun timing{formatOutdoorSunTimings(room.outdoorSunTimings)} Light quality{formatGardenValue(room.outdoorLightQuality || 'unsure')} Location context{approximateLocationContext || 'Not set'} ) : ( <> Window{formatWindowDirections(room.windowDirections)} Light{formatLightProfile(room.lightProfile)} )} Ambient artificial light{formatAmbientArtificialLight(room)} Humidity{formatHumidityProfile(room.humidityProfile)} Temperature{formatGardenValue(room.temperatureEstimate)} {isIndoorSpaceType(room.spaceType) ? ( Climate & air{formatClimateAir(room)} ) : null} {cleanSpaceNotesForDisplay(room.notes) ? {cleanSpaceNotesForDisplay(room.notes)} : null} ) : null} {/* ASSIGNED PLANTS — collapsible, persisted. The "Assign existing plants" button stays visible in BOTH states (Bekky's spec). */}
toggleSpaceTile(room, 'plants'))} accessibilityRole="button" accessibilityState={{ expanded: !isSpaceTileCollapsed(room, 'plants') }} > {(!isSpaceTileCollapsed(room, 'plants') && assignedItems.length) ? assignedItems.map(item => { const plantName = getPlantItemName(item); const plantImage = getPlantItemLaunchImage(item); return ( openPlantDetail( item.kind === 'saved' ? { kind: 'saved', plant: item.plant } : { kind: 'sample', plant: item.plant }, { kind: 'space', id: room.id }, ))} > {plantImage.source ? ( ) : ( P )} {plantName} ); }) : (!isSpaceTileCollapsed(room, 'plants') ? {hasGardenPlants ? 'This space is waiting for its first leafy friend.' : 'Add your first plant, then give it a cozy spot here.'} : null)} {hasGardenPlants ? ( openAssignPlantsForSpace(room.id))} accessibilityRole="button" accessibilityLabel={`Assign existing plants to ${room.name}`} > Assign existing plants ) : null} {isAssigningThisSpace ? (
Cancel {orderedGardenItems.map(item => { const key = getPlantItemId(item); const selected = assignPlantKeys.includes(key); const spaceName = getSpaceNameById(getPlantItemSpaceId(item)); const plantImage = getPlantItemLaunchImage(item); return ( toggleAssignPlant(key))} accessibilityRole="button" accessibilityLabel={`${selected ? 'Deselect' : 'Select'} ${getPlantItemName(item)}`} > {plantImage.source ? ( ) : ( P )} {getPlantItemName(item)} {spaceName ? `Currently in ${spaceName}` : 'No space assigned'} {selected ? 'Selected' : 'Select'} ); })} saveAssignedPlantsForSpace(room.id)} onCancel={cancelAssignPlants} saving={savingGardenForm} /> ) : null} {/* Care Groups listing (Chunk A, Bekky 2026-08-29): the space that owns groups informationally lists them — how many groups + which plants are members. Tapping a group's plant opens its detail. */} {room.cohorts && (room.cohorts.water?.length || room.cohorts.fertilize?.length) ? (
{(['water', 'fertilize'] as const).map(action => { const cohorts = room.cohorts?.[action] || []; if (!cohorts.length) return null; const actionLabel = action === 'water' ? '💧 Watering' : '🌱 Feeding'; return ( {actionLabel} {cohorts.map(cohort => { const memberPlants = cohort.plantIds .map(id => savedPlants.find(p => p.id === id)) .filter((p): p is SavedPlantProfile => Boolean(p)); const memberNames = memberPlants.map(p => getSavedPlantDisplayName(p)); const ck = `space:${room.id}:${cohort.id}:${action}`; const expanded = isCohortExpanded(room, ck); const doneLabel = (() => { const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const fmt = (key: string) => { const [, m, d] = key.split('-').map(Number); return `${monthNames[m - 1]} ${d}`; }; const dates = memberPlants .map(p => p.careSchedule?.lastDone?.[action]) .filter((d): d is string => Boolean(d)) .sort(); if (!dates.length) return ''; const uniq = Array.from(new Set(dates)); if (uniq.length === 1) return `Last ${action === 'water' ? 'watered' : 'fed'} ${fmt(uniq[0])}`; return `Last ${action === 'water' ? 'watered' : 'fed'} ${fmt(uniq[0])} – ${fmt(uniq[uniq.length - 1])} (varies by plant)`; })(); return ( {/* Header row — tap the ROW to expand; chevron + pencil are the outlined 30×30 boxes copied from the plant-detail care-guidance style. */} toggleCohortExpanded(room, ck))} accessibilityRole="button" accessibilityState={{ expanded }} > {cohort.name} {memberNames.length} {memberNames.length === 1 ? 'plant' : 'plants'}{(!expanded && memberNames.length) ? `: ${memberNames.join(', ')}` : ''} {(() => { const cad = cohort.sharedCadence?.[action]; const cadBit = cad && cad > 0 ? `Every ${cad} days` : ''; const bits = [cadBit, doneLabel].filter(Boolean); return bits.length ? `\n${bits.join(' · ')}` : ''; })()} setCohortDateEdit({ spaceId: room.id, cohortId: cohort.id, action, cohortName: cohort.name, plantIds: memberPlants.map(p => p.id) }))} accessibilityRole="button" accessibilityLabel={`Edit last ${action === 'water' ? 'watered' : 'fertilized'} date for ${cohort.name}`} > {action === 'fertilize' ? ( { const current = memberPlants.map(p => setupByPlantId.get(savedGardenKey(p.id))?.fertilizer).find(Boolean) || ''; setCohortFertilizerInput(current); setCohortFertilizerEdit({ cohortName: cohort.name, plantIds: memberPlants.map(p => p.id) }); })} accessibilityRole="button" accessibilityLabel={`Set fertilizer for ${cohort.name}`} > 🌿 ) : null} toggleCohortExpanded(room, ck))} accessibilityRole="button" accessibilityState={{ expanded }} > {!expanded ? '▸' : '▾'} {/* Expanded: member TILES (Bekky, 2026-08-30) — the garden/wishlist tile design, smaller, so each plant reads at a glance by photo; tappable to open detail. */} {expanded ? ( {memberPlants.map(p => { const tileUri = getSavedPlantImageUri(p); return ( openPlantDetail({ kind: 'saved', plant: p }, { kind: 'space', id: room.id }))} accessibilityRole="button" accessibilityLabel={`Open ${getSavedPlantDisplayName(p)} detail`} > {tileUri ? ( ) : ( 🌿 )} {getSavedPlantDisplayName(p)} ); })} ) : null} ); })} ); })} ) : null} openSpaceForm(room, { returnDetail: { kind: 'space', id: room.id }, scrollToForm: true }))}>Edit Space { deleteSpace(room.id); setGardenDetail(null); }, haptic.warning)}>Delete {activeForm === 'space' ? ( { spaceFormYRef.current = event.nativeEvent.layout.y; scrollDetailToForm('space', event.nativeEvent.layout.y); }}> {gardenFormCard} ) : gardenFormCard} {deleteConfirmModal} { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void takeGardenPhoto(cb); }} onChoosePhoto={() => { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void chooseGardenPhotoFromLibrary(cb); }} onCancel={() => { setShowPhotoPicker(false); pendingPhotoCallbackRef.current = null; }} /> {photoDeleteConfirmModal} { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'compressed'); } }, { text: 'Full quality', style: 'default', onPress: () => { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'original'); } }, { text: 'Cancel', style: 'cancel', onPress: () => setExportQualityChoice(null) }, ]} onRequestClose={() => setExportQualityChoice(null)} /> {/* GROUP PENCIL (Bekky, 2026-08-30, chunk B-adjacent — Option A): edit the GROUP's shared last-watered date. Writes lastDone for EVERY member ('manual' path) — the user-facing door to the group anchor reset. Lives in the SPACE branch (fix 2026-08-30: was mistakenly mounted in the kit branch, so the pencil appeared dead on device). */} { if (!cohortDateEdit) return null; const dates = cohortDateEdit.plantIds .map(pid => savedPlants.find(sp => sp.id === pid)?.careSchedule?.lastDone?.[cohortDateEdit.action]) .filter((d): d is string => Boolean(d)) .sort(); return dates.length ? dates[dates.length - 1] : null; })()} accent={cohortDateEdit?.action === 'water' ? '#2E7DB8' : green} dateOnly onFileLine={(() => { if (!cohortDateEdit) return undefined; const dates = cohortDateEdit.plantIds .map(pid => savedPlants.find(sp => sp.id === pid)?.careSchedule?.lastDone?.[cohortDateEdit.action]) .filter((d): d is string => Boolean(d)) .sort(); if (dates.length === 0) return undefined; const uniq = Array.from(new Set(dates)); const fmt = (key: string) => { const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const [, m, d] = key.split('-').map(Number); return `${monthNames[m - 1]} ${d}`; }; return uniq.length === 1 ? `On file: ${fmt(uniq[0])}` : `On file: ${fmt(uniq[0])} – ${fmt(uniq[uniq.length - 1])} (varies by plant)`; })()} secondaryLabel="Cancel" onSave={(_days, lastDone) => { if (cohortDateEdit && lastDone) { onSpaceWaterAll?.({ spaceId: cohortDateEdit.spaceId, plantIds: cohortDateEdit.plantIds, action: cohortDateEdit.action, date: lastDone }); } setCohortDateEdit(null); }} onUseDefault={() => setCohortDateEdit(null)} onClose={() => setCohortDateEdit(null)} /> {/* COHORT FERTILIZER EDIT (Bekky, 2026-09-02): confirm + input for a whole fertilize cohort's shared fertilizer. Writes to every member. */} setCohortFertilizerEdit(null)} statusBarTranslucent navigationBarTranslucent > Fertilizer for "{cohortFertilizerEdit?.cohortName}" This updates the fertilizer for every plant in this group ({cohortFertilizerEdit?.plantIds.length || 0} plants). setCohortFertilizerEdit(null))}> Cancel { if (cohortFertilizerEdit) { cohortFertilizerEdit.plantIds.forEach(pid => setPlantFertilizer(savedGardenKey(pid), cohortFertilizerInput)); } setCohortFertilizerEdit(null); }, haptic.success)}> Update group ); } } if (selectedPlant) { const isSavedPlant = selectedPlant.kind === 'saved'; const savedPlant = isSavedPlant ? selectedPlant.plant : undefined; // MANUAL REFRESH WINDOW (Bekky, 2026-09-04): the "Save and refresh Pixie // Intelligence" button is an ONBOARDING aid — it exists so the user can // finish entering environment/placement/setup before care fires. It shows // only for the first 24h after the plant is created, then disappears and // the auto-care cadence takes over (auto-care already waits 24h before // generating in the background, so the two hand off cleanly). const manualRefreshWindowOpen = isSavedPlant && !!savedPlant ? (() => { const created = new Date(savedPlant.createdAt).getTime(); if (Number.isNaN(created)) return true; // missing date → keep it visible return (Date.now() - created) < 24 * 60 * 60 * 1000; })() : false; // SHARED CONTAINER (Bekky, 2026-09-04): the roommates of the selected plant — // other saved plants sharing the same sharedContainerId. Used by the Container // section on the detail page. const roommatesForSelectedPlant = isSavedPlant && savedPlant?.sharedContainerId ? savedPlants.filter(p => p.id !== savedPlant.id && p.sharedContainerId === savedPlant.sharedContainerId) : []; const detailTitle = isSavedPlant ? getSavedPlantDisplayName(selectedPlant.plant) : getSamplePlantDisplayName(selectedPlant.plant); const detailPlantImage = isSavedPlant ? getLaunchPlantImageSource(selectedPlant.plant) : getLaunchPlantImageSource(null); const statusText = isSavedPlant ? `${selectedPlant.plant.confidence}% confidence` : selectedPlant.plant.status; const selectedPlantKey = isSavedPlant ? savedGardenKey(selectedPlant.plant.id) : sampleGardenKey(selectedPlant.plant.name); const selectedSetup = setupByPlantId.get(selectedPlantKey); const selectedAssignedSpaceId = isSavedPlant ? resolveStoredPlantSpaceId(selectedPlant.plant, rooms) : selectedPlant.plant.spaceId; const assignedRoom = rooms.find(room => room.id === selectedAssignedSpaceId); const setupRoom = assignedRoom; const selectedPlantEvents = getSavedPlantEvents(savedPlant); const selectedSetupPhotos = normalizeContextPhotos(selectedSetup?.setupPhotos, [selectedSetup?.setupPhotoUri, selectedSetup?.photoUris]); const selectedEnvironmentPhotos = normalizeContextPhotos(setupRoom?.environmentPhotos, [setupRoom?.environmentPhotoUri, setupRoom?.photoUri]); const setupMediums = selectedSetup ? (selectedSetup.mediumTypes?.length ? selectedSetup.mediumTypes : selectedSetup.mediumType && selectedSetup.mediumType !== 'unsure' ? [selectedSetup.mediumType as string] : []) : []; // CUSTOM MIX (Bekky, 2026-09-04): a user can build a custom mix from // components (customMediumComponents) WITHOUT picking a store-bought mix. // Those components ARE the growing medium — they must count as "present" // so the summary doesn't wrongly say "Missing" and the JSON packet carries // them. Merge them into setupMediums for display + packet building. const customComponents = selectedSetup?.customMediumComponents?.length ? selectedSetup.customMediumComponents.filter(c => c !== 'unsure') : []; const effectiveSetupMediums = setupMediums.length ? setupMediums : customComponents.length ? customComponents : []; const setupRoomIsOutdoor = isOutdoorSpaceType(setupRoom?.spaceType); // A plant is "in the ground" (no container) if the user said so via // plantedIn (ground / raised_bed), OR the potType resolves to an in-ground // value. When in the ground, pot size/drainage/container type are unneeded // (Bekky, 2026-08-23). const setupIsInGround = selectedSetup ? (selectedSetup.plantedIn === 'ground' || selectedSetup.plantedIn === 'raised_bed' || isInGroundPotType(selectedSetup.potType)) : false; const setupPotIsInGround = setupIsInGround; const scanIdentification = savedPlant?.identification; const identificationName = scanIdentification?.commonName || savedPlant?.commonName || ''; const identificationScientificName = scanIdentification?.scientificName || savedPlant?.scientificName || ''; const identificationConfidence = typeof scanIdentification?.confidence === 'number' ? scanIdentification.confidence : savedPlant?.confidence || 0; const identificationSuggestions: SavedPlantIdentificationSuggestion[] = scanIdentification?.suggestions?.length ? scanIdentification.suggestions : (savedPlant?.alternatives || []).map((name: string) => ({ name })); const alternateIdentificationSuggestions = scanIdentification?.suggestions?.length ? identificationSuggestions.slice(1, 4) : identificationSuggestions.slice(0, 3); const changeIdSuggestions = scanIdentification?.suggestions || []; const isWishlistDetail = isSavedPlant && selectedPlant.plant.source === 'wishlist'; const handleChangeIdSave = () => { if (selectedPlant?.kind !== 'saved') return; if (isRenameMode) { const newName = idCorrectionName.trim(); if (!newName) return; onUpdatePlant(selectedPlant.plant.id, { name: newName, } as Partial); setShowIdCorrection(false); setIsRenameMode(false); setIdCorrectionName(''); setChangeIdSelectedIndex(null); setChangeIdSearch(''); return; } let newName = ''; let newScientific = ''; let newConfidence = 100; if (changeIdSelectedIndex !== null && changeIdSuggestions[changeIdSelectedIndex]) { const s = changeIdSuggestions[changeIdSelectedIndex]; newName = cleanPlantDisplayName(s.commonName || s.name || s.scientificName || ''); newScientific = s.scientificName || ''; newConfidence = s.confidence || 0; } else if (idCorrectionName.trim()) { newName = idCorrectionName.trim(); newScientific = idCorrectionScientific.trim(); newConfidence = 100; } if (!newName) return; onUpdatePlant(selectedPlant.plant.id, { commonName: newName, scientificName: newScientific || undefined, confidence: newConfidence, identification: selectedPlant.plant.identification ? { ...selectedPlant.plant.identification, commonName: newName, scientificName: newScientific || '', confidence: newConfidence, provider: changeIdSelectedIndex !== null ? 'user-correction' : 'user-manual', identifiedAt: new Date().toISOString(), } : undefined, }); setShowIdCorrection(false); setChangeIdSelectedIndex(null); setChangeIdSearch(''); }; const idCorrectionModal = ( setShowIdCorrection(false)} statusBarTranslucent navigationBarTranslucent hardwareAccelerated> {/* Header */} { setShowIdCorrection(false); setChangeIdSelectedIndex(null); setChangeIdSearch(''); setIsRenameMode(false); }}> Cancel {isRenameMode ? 'Rename Plant' : 'Change Identification'} {isRenameMode ? ( <> Give your plant a custom name to help organize your garden. The scientific name stays in the details. Custom name ) : ( <> If Pixie picked the wrong plant, choose another match or enter the correct name. {/* Section 1: Current identification */} Current identification {identificationName || 'Unknown'} {identificationScientificName ? {identificationScientificName} : null} {identificationConfidence ? Confidence: {identificationConfidence}% : null} {/* Section 2: Suggested matches */} {changeIdSuggestions.length > 0 ? ( <> Suggested matches {changeIdSuggestions.map((suggestion, index) => { const isSelected = changeIdSelectedIndex === index; const sName = cleanPlantDisplayName(suggestion.commonName || suggestion.name || suggestion.scientificName || ''); return ( { setChangeIdSelectedIndex(index); setIdCorrectionName(suggestion.commonName || suggestion.name || ''); setIdCorrectionScientific(suggestion.scientificName || ''); setChangeIdSearch(''); })} > {sName} {suggestion.scientificName && suggestion.scientificName !== sName ? ( {suggestion.scientificName} ) : null} {typeof suggestion.confidence === 'number' ? ( {suggestion.confidence}% ) : null} ); })} ) : null} {/* Section 3: Search or enter manually */} Search or enter manually Common name { setChangeIdSelectedIndex(null); setIdCorrectionName(text); setChangeIdSearch(text); }} placeholder="Enter common name..." placeholderTextColor="#A09888" autoCapitalize="words" /> Scientific name (optional) { setChangeIdSelectedIndex(null); setIdCorrectionScientific(text); }} placeholder="e.g. Rosmarinus officinalis" placeholderTextColor="#A09888" autoCapitalize="none" /> )} {/* Save button - pinned to bottom */} Save ); const editPlacementModal = ( setShowEditPlacement(false)} statusBarTranslucent navigationBarTranslucent hardwareAccelerated> {/* Header */} setShowEditPlacement(false)}> Cancel Edit Placement Where exactly does this plant sit inside its space? This helps Pixie understand light, airflow, and surroundings. {/* Placement Description */} Placement description {placementNeedsText ? ( ! ) : null} { setPlacementDescriptionInput(t); if (placementNeedsText) setPlacementNeedsText(false); }} placeholder="e.g. East-facing windowsill, back corner near door..." placeholderTextColor="#A09888" autoCapitalize="sentences" multiline /> {placementNeedsText ? ( Add a placement description (or a photo) so Pixie can save this. ) : null} {/* Proximity to light & air sources (Bekky, 2026-08-22, placement pass). Only shows when the plant is ASSIGNED to a space AND that space is INDOOR (a window/climate-device proximity needs to know the room, and only makes sense inside). If no space is assigned yet, or the space is outdoor, skip the whole section (Bekky, 2026-08-23). */} {setupRoom && !isOutdoorSpaceType(setupRoom?.spaceType) ? ( <> Distance to light & air How close is this plant to the window and any climate devices in this space? {buildProximityRows(setupRoom, placementProximity, setPlacementProximity)} ) : null} {/* Neighbors — plants physically near this one in the SAME space (Bekky, 2026-08-27, Chunk 1). Symmetric (A↔B mutual), space-scoped (only same-space plants are pickable), optional. Powers outbreak awareness + intelligent watering grouping. The WHOLE section is hidden when there are no other plants in the space — no "come back" note, because the user tags neighbors from the OTHER plant's edit-placement when they add it (Bekky, 2026-08-27). */} {isSavedPlant && selectedAssignedSpaceId && getSavedPlantsForSpace(selectedAssignedSpaceId).some(p => p.id !== selectedPlant.plant.id) ? ( <> Neighbors Which plants are close enough to share water or spread disease? Tap the ones next to this plant (touching, or within arm's reach). Optional. {getSavedPlantsForSpace(selectedAssignedSpaceId) .filter(p => p.id !== selectedPlant.plant.id) .map(p => { const isNeighbor = neighborPlantIds.includes(p.id); return ( { setNeighborPlantIds(current => isNeighbor ? current.filter(id => id !== p.id) : [...current, p.id] ); })} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, borderWidth: 1.5, borderColor: isNeighbor ? '#2E6B3F' : '#B8C4A8', backgroundColor: isNeighbor ? '#E8F5D3' : '#FFF', paddingHorizontal: 10, paddingVertical: 6, }} > {p.photoUri ? ( ) : null} {getSavedPlantDisplayName(p)} ); })} ) : null} {/* Placement Photo — up to 3, min 1 recommended */} Placement photos showPhotoGuidance('placement'))} accessibilityRole="button" accessibilityLabel="How to take a good placement photo" > ? Show where this plant lives. Up to 3 photos, at least 1 recommended. {placementPhotos.length ? ( {placementPhotos.map((photo, index) => ( setPlacementViewerUri(photo.uri))}> setPendingPhotoDelete({ kind: 'placement', id: photo.id, confirm: () => { setPlacementPhotos(current => { const next = current.filter(p => p.id !== photo.id); setPlacementPhotoUri(next[0]?.uri || null); return next; }); }, }))} accessibilityRole="button" accessibilityLabel="Remove placement photo" > Remove ))} {placementPhotos.length < 3 ? ( openPhotoPickerWithGuidance('placement', (uri, extra) => { setPlacementPhotos(current => { if (current.length >= 3) return current; const next = [...current, makeContextPhoto(uri, undefined, extra)]; setPlacementPhotoUri(next[0].uri); return next; }); }))} style={{ width: 92, height: 92, borderRadius: 12, borderWidth: 1.5, borderStyle: 'dashed', borderColor: '#8FAE8F', backgroundColor: '#F2F7EC', alignItems: 'center', justifyContent: 'center' }} > ) : null} openPhotoPickerWithGuidance('placement', (uri, extra) => { setPlacementPhotos(current => { if (current.length >= 3) return current; const next = [...current, makeContextPhoto(uri, undefined, extra)]; setPlacementPhotoUri(next[0].uri); return next; }); }))} > {placementPhotos.length >= 3 ? '3 photos added' : 'Add Photo'} ) : ( openPhotoPickerWithGuidance('placement', (uri, extra) => { const next = [makeContextPhoto(uri, undefined, extra)]; setPlacementPhotos(next); setPlacementPhotoUri(uri); }))} > Add Photo )} {/* Save button - pinned to bottom */} { if (!isSavedPlant || !selectedPlant) return; // Standard validation: placement needs a description to persist. // If there's no text (and no photo), block the save and flash a // red exclamation on the field so the user knows why (Bekky, 2026-08-23). const hasText = placementDescriptionInput.trim().length > 0; const hasPhoto = placementPhotos.length > 0; if (!hasText && !hasPhoto) { setPlacementNeedsText(true); placementWarnPulse.setValue(0); Animated.loop( Animated.sequence([ Animated.timing(placementWarnPulse, { toValue: 1, duration: 550, useNativeDriver: true }), Animated.timing(placementWarnPulse, { toValue: 0, duration: 550, useNativeDriver: true }), ]), { iterations: 4 } ).start(); return; } setPlacementNeedsText(false); const newUris = placementPhotos.map(p => p.uri); onUpdatePlant(selectedPlant.plant.id, { placementDescription: placementDescriptionInput.trim() || undefined, placementPhoto: newUris[0] || undefined, placementPhotos: placementPhotos.length ? placementPhotos : undefined, placementProximity: Object.keys(placementProximity).length ? placementProximity : undefined, neighborPlantIds: neighborPlantIds.length ? neighborPlantIds : undefined, }); // SYMMETRIC neighbor write (Bekky, 2026-08-27, Chunk 1): if A tags // B as a neighbor, B automatically gets A. Mirror the change onto // every neighbor plant so the relationship is mutual — correct for // outbreak logic (if A is sick, B is at risk regardless of who // tagged whom) and halves the user's effort. const currentNeighbors = new Set(selectedPlant.plant.neighborPlantIds || []); const newNeighbors = new Set(neighborPlantIds); for (const neighborId of neighborPlantIds) { const neighbor = savedPlants.find(p => p.id === neighborId); if (!neighbor) continue; const neighborList = new Set(neighbor.neighborPlantIds || []); neighborList.add(selectedPlant.plant.id); onUpdatePlant(neighborId, { neighborPlantIds: Array.from(neighborList) }); } // Remove this plant from any neighbor that was untagged. for (const removedId of currentNeighbors) { if (newNeighbors.has(removedId)) continue; const neighbor = savedPlants.find(p => p.id === removedId); if (!neighbor) continue; const neighborList = (neighbor.neighborPlantIds || []).filter(id => id !== selectedPlant.plant.id); onUpdatePlant(removedId, { neighborPlantIds: neighborList.length ? neighborList : undefined }); } setShowEditPlacement(false); // AI no longer fires on placement save — the single "Save and // refresh Pixie Intelligence" button handles text enrichment + // photo analysis together (Bekky, 2026-08-19). Flash that button // so the user notices it's the next step. flashRefreshButton(); })} > Save ); const plantBackLabel = activeForm ? 'Plant' : plantReturnDetail?.kind === 'space' ? rooms.find(room => room.id === plantReturnDetail.id)?.name || 'Space' : 'Plants'; const plantDetailSubtitle = plantReturnDetail?.kind === 'space' ? `Garden / Spaces / ${rooms.find(room => room.id === plantReturnDetail.id)?.name || 'Space'}` // Plant detail: no "Garden / Plants" pathway (Bekky, 2026-08-13) — it's // noise. The title itself already says the plant; let it breathe. : undefined; const plantIntelligence = buildPlantIntelligenceSummary({ plant: savedPlant, samplePlant: isSavedPlant ? undefined : selectedPlant.plant, setup: selectedSetup, room: setupRoom, linkedKitCount: 0, }); const providerSource = isSavedPlant ? formatIdentificationProvider(scanIdentification?.provider || selectedPlant.plant.scanProvider) : ''; const identificationSource = isSavedPlant ? providerSource || (selectedPlant.plant.source === 'plant_scan' ? 'Scan' : selectedPlant.plant.source === 'manual_garden' ? 'Manual entry' : 'Unknown') : 'Demo plant'; const intelligenceCommonName = isSavedPlant ? cleanPlantDisplayName(identificationName || selectedPlant.plant.commonName) : getSamplePlantDisplayName(selectedPlant.plant); const intelligenceScientificSource = isSavedPlant ? identificationScientificName || selectedPlant.plant.scientificName : ''; const intelligenceScientificName = intelligenceScientificSource ? cleanScientificName(intelligenceScientificSource) : ''; const identityRows = [ ['Common name', intelligenceCommonName], intelligenceScientificName ? ['Scientific name', intelligenceScientificName] : undefined, isSavedPlant && (savedPlant?.name || savedPlant?.name !== savedPlant?.commonName) ? ['Custom name', savedPlant?.name || ''] : undefined, ['Identification source', identificationSource], isSavedPlant && selectedPlant.plant.source === 'plant_scan' && typeof identificationConfidence === 'number' ? ['Scan confidence', `${identificationConfidence}%`] : undefined, isSavedPlant && selectedPlant.plant.source === 'plant_scan' ? ['Scan date', formatDetailDate(scanIdentification?.identifiedAt || selectedPlant.plant.createdAt)] : undefined, isSavedPlant && selectedPlant.plant.growthStage && selectedPlant.plant.growthStage !== 'unsure' ? ['Growth stage', formatGardenValue(selectedPlant.plant.growthStage)] : undefined, isSavedPlant && selectedPlant.plant.timeOwned && selectedPlant.plant.timeOwned !== 'unsure' ? ['Time owned', formatGardenValue(selectedPlant.plant.timeOwned)] : undefined, ].filter((row): row is string[] => Boolean(row?.[1])); const environmentRows = [ ['Assigned space', setupRoom?.name || 'Missing'], ['Indoor/outdoor', setupRoom ? (isOutdoorSpaceType(setupRoom.spaceType) ? 'Outdoor' : 'Indoor') : 'Missing'], setupRoom ? ['Space type', formatSpaceType(setupRoom.spaceType)] : undefined, setupRoom && isIndoorSpaceType(setupRoom.spaceType) ? ['Window direction', formatWindowDirections(setupRoom.windowDirections)] : undefined, setupRoom && isOutdoorSpaceType(setupRoom.spaceType) ? ['Sun timing', formatOutdoorSunTimings(setupRoom.outdoorSunTimings)] : undefined, setupRoom ? ['Light quality', isOutdoorSpaceType(setupRoom.spaceType) ? formatGardenValue(setupRoom.outdoorLightQuality || 'unsure') : formatLightProfile(setupRoom.lightProfile)] : undefined, setupRoom ? ['Ambient artificial light', formatAmbientArtificialLight(setupRoom)] : undefined, ['Environment photos', formatPhotoCount(selectedEnvironmentPhotos)], approximateLocationContext ? ['ZIP / nearby city', approximateLocationContext] : undefined, ].filter((row): row is string[] => Boolean(row?.[1])); const setupRows = [ // In-ground plants have no container — hide planting/container type, pot // size, and drainage entirely (they're not "missing", just unneeded) // (Bekky, 2026-08-23). selectedSetup && !setupPotIsInGround && selectedSetup.potType && selectedSetup.potType !== 'unsure' ? ['Planting/container type', formatGardenValue(selectedSetup.potType)] : undefined, selectedSetup && !setupPotIsInGround ? ['Pot size', selectedSetup.potSize !== 'unsure' && selectedSetup.potSize !== 'not_applicable' && selectedSetup.potSize ? formatGardenValue(selectedSetup.potSize) : 'Missing'] : undefined, selectedSetup && !setupPotIsInGround ? ['Drainage', selectedSetup.drainage !== 'unsure' && selectedSetup.drainage !== 'not_applicable' && selectedSetup.drainage ? formatGardenValue(selectedSetup.drainage) : 'Missing'] : undefined, ['Growing medium / soil', effectiveSetupMediums.length ? effectiveSetupMediums.map((m: string) => formatGardenValue(m)).join(', ') + (selectedSetup?.customMedium ? ` (${selectedSetup.customMedium})` : '') : 'Missing'], ['Top dressing', selectedSetup?.topDressing?.length ? selectedSetup.topDressing.map((d: string) => formatGardenValue(d)).join(', ') + (selectedSetup?.customTopDressing ? ` (${selectedSetup.customTopDressing})` : '') : 'None'], ['Watering method', (selectedSetup?.wateringMethods?.length ? selectedSetup.wateringMethods.map((w: string) => formatGardenValue(w)).join(', ') + (selectedSetup?.customWatering ? ` (${selectedSetup.customWatering})` : '') : (selectedSetup && selectedSetup.wateringMethod && selectedSetup.wateringMethod !== 'unsure' ? formatGardenValue(selectedSetup.wateringMethod) : 'Not added yet'))], ['Dedicated plant light', selectedSetup ? formatDedicatedArtificialLight(selectedSetup) : 'Missing'], ['Setup photos', formatPhotoCount(selectedSetupPhotos)], ['Setup completed', plantIntelligence.hasSetupDetails ? 'Complete' : 'Incomplete'], ].filter((row): row is string[] => Boolean(row?.[1])); const careReadinessSummary = !plantIntelligence.hasIdentification ? 'Pixie needs plant identity details before future care guidance will be very useful.' : !plantIntelligence.hasAssignedSpace ? 'Assign this plant to a space so Pixie understands where it lives.' : !plantIntelligence.hasSetupDetails ? 'Pixie still needs setup details before care guidance will be very useful.' : !plantIntelligence.hasEnvironmentDetails ? "Add environment details to help Pixie understand this plant's light." : !plantIntelligence.hasSetupPhoto ? 'A setup photo would help Pixie understand how this plant is actually growing.' : !plantIntelligence.hasEnvironmentPhoto ? 'An environment photo would help Pixie understand light, placement, airflow, and nearby plants.' : !plantIntelligence.hasPlacement ? 'Add a placement photo so Pixie can better understand this plant\'s location within the space.' : 'Pixie has a good starting picture for this plant.'; const openPlantIntelligenceAction = (action?: PlantIntelligenceAction, comingSoon?: boolean) => { if (!action) return; if (comingSoon || action === 'add_progress_photo') { Alert.alert('Coming soon', 'Progress photo history is not connected yet. You are still on this plant.'); return; } if (action === 'review_identification' && isSavedPlant) { openPlantForm(selectedPlant.plant, { scrollToForm: true }); return; } if (action === 'add_setup_details') { openSetupForm(selectedPlantKey, 'section'); return; } if (action === 'add_setup_photo') { openSetupForm(selectedPlantKey, 'setupPhoto'); return; } if (action === 'add_watering_method') { openSetupForm(selectedPlantKey, 'wateringMethod'); return; } if (action === 'add_dedicated_light') { openSetupForm(selectedPlantKey, 'dedicatedLight'); return; } if (action === 'complete_environment' || action === 'add_environment_photo') { if (!setupRoom) { Alert.alert('Assign a space first', 'Choose where this plant lives before adding environment details.'); return; } openSpaceForm(setupRoom, { returnDetail: { kind: 'space', id: setupRoom.id }, scrollToForm: true, targetField: action === 'add_environment_photo' ? 'environmentPhoto' : 'environmentDetails', }); return; } if (action === 'assign_space' && isSavedPlant) { openPlantForm(selectedPlant.plant, { scrollToForm: true, targetField: 'assignedSpace' }); } }; const openNextBestStepAction = () => { const action = plantIntelligence.nextBestActionKey; if (!action || plantIntelligence.nextBestActionComingSoon) return; openPlantIntelligenceAction(action, false); }; const compactKnownItems = plantIntelligence.knowledgeSummary.compactKnown; return (
{detailPlantImage.source ? ( ) : ( Plant )} {statusText}{isSavedPlant ? `${selectedPlant.plant.confidence}%` : '78%'} {isSavedPlant && selectedPlant.plant.scanMode === 'fungus' ? ( {selectedPlant.plant.kingdom ? ( What is it: {selectedPlant.plant.kingdom === 'mushroom' ? '🍄 Mushroom' : selectedPlant.plant.kingdom === 'bracket_fungus' ? '🪵 Bracket / shelf fungus' : selectedPlant.plant.kingdom === 'slime_mold' ? '🫠 Slime mold' : selectedPlant.plant.kingdom === 'lichen' ? '🪨 Lichen' : selectedPlant.plant.kingdom === 'moss' ? '🌿 Moss' : selectedPlant.plant.kingdom === 'plant' ? '🌱 A plant (not a fungus)' : 'Other organism'} ) : null} {selectedPlant.plant.edibility ? ( Edibility (unverified): {selectedPlant.plant.edibility === 'edible' ? 'Reported edible' : selectedPlant.plant.edibility === 'poisonous' ? 'Poisonous — do not eat' : 'Unknown'} ) : null} {selectedPlant.plant.cultivable ? ( Cultivable: Yes{selectedPlant.plant.substrate ? ` · grows on ${selectedPlant.plant.substrate}` : ''} ) : null} ⚠️ 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. Confirm with a local expert before consuming anything. ) : null} toggleSectionCollapsed('identity')} > {identityRows.map(([k, v]) => ( {k} {v} ))} {isSavedPlant && scanIdentification ? Pixie does its best, but plant identification is not always perfect. Compare details and double-check before making important care, safety, or consumption decisions. : null} {isSavedPlant && scanIdentification?.description ? ( <> Possible match details {scanIdentification.description} ) : null} {isSavedPlant && alternateIdentificationSuggestions.length ? ( <> Other possible matches {alternateIdentificationSuggestions.map((suggestion, index) => { const suggestionName = cleanPlantDisplayName(suggestion.name || suggestion.commonName || suggestion.scientificName); return ( {suggestionName} {typeof suggestion.confidence === 'number' ? ( {suggestion.confidence}% ) : null} ); })} ) : null} {isSavedPlant && !isWishlistDetail ? ( { setIdCorrectionName(''); setIdCorrectionScientific(''); setShowIdCorrection(true); })} > Not your plant? Change ID ) : null} {/* About-this-plant action buttons (Repot / Duplicate / Set Plant Nickname). Rendered in BOTH the collapsed footer AND the expanded body so they're always reachable (Bekky, 2026-08-27). */} {(() => { const aboutActions = isSavedPlant && !isWishlistDetail ? ( {selectedSetup && !setupPotIsInGround ? ( { if (!isSavedPlant || !selectedPlant) return; // Repot — always-available action (Bekky, 2026-08-13). // Opens a light-themed guidance modal. Guidance is fetched // ON DEMAND (Bekky, 2026-08-15) — don't call the AI until the // user asks for help, to save API calls. const plant = selectedPlant.plant; setRepotPlantName(getSavedPlantDisplayName(plant)); setRepotGuidance(null); setRepotLoading(false); setRepotGuidanceRequested(false); setShowRepotModal(true); })} > Repot ) : null} { if (!isSavedPlant || !selectedPlant) return; const plant = selectedPlant.plant; const duplicate: SavedPlantProfile = { ...plant, id: `plant_scan_${Date.now()}`, name: plant.commonName || undefined, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), spaceId: null, assignedSpaceId: undefined, events: [], plantEvents: [], setupCompleted: false, }; onSavePlant(duplicate); setDuplicateConfirmation({ title: 'Duplicated!', message: `${duplicate.name || duplicate.commonName || 'This plant'} has been added to your Garden.`, }); })} > Duplicate {isSavedPlant ? ( { if (!isSavedPlant || !selectedPlant) return; setIsRenameMode(true); setIdCorrectionName(selectedPlant.plant.name || selectedPlant.plant.commonName || ''); setShowIdCorrection(true); })} > Set Plant Nickname ) : null} ) : null; return ( toggleSectionCollapsed('about')} collapsedFooter={aboutActions} > {isSavedPlant && (savedPlant?.enrichmentData?.bio || scanIdentification?.description) ? ( {savedPlant?.enrichmentData?.bio || scanIdentification?.description} ) : null} {isSavedPlant && savedPlant?.enrichmentData && (savedPlant.enrichmentData.lightNeeds || savedPlant.enrichmentData.waterNeeds || savedPlant.enrichmentData.soilNeeds || savedPlant.enrichmentData.humidity) ? ( {savedPlant.enrichmentData.lightNeeds ? 💡 {savedPlant.enrichmentData.lightNeeds} : null} {savedPlant.enrichmentData.waterNeeds ? 💧 {savedPlant.enrichmentData.waterNeeds} : null} {savedPlant.enrichmentData.soilNeeds ? 🌱 {savedPlant.enrichmentData.soilNeeds} : null} {savedPlant.enrichmentData.humidity ? 💦 {savedPlant.enrichmentData.humidity} : null} ) : null} {isSavedPlant && (scanIdentification?.referenceUrl || savedPlant?.enrichmentData?.wikiUrl) ? ( { const url = scanIdentification?.referenceUrl || savedPlant?.enrichmentData?.wikiUrl || ''; try { await Linking.openURL(url); } catch { Alert.alert('Link unavailable', 'That Wikipedia page could not be opened right now.'); } })} > Read more on Wikipedia ) : null} {isSavedPlant && scanIdentification?.notes ? ( <> User scan notes {scanIdentification.notes} ) : null} {aboutActions} ); })()} {/* Phase 3 — cutting graduation CTA (only for cuttings) */} {isSavedPlant && savedPlant && isCuttingProfile(savedPlant) && (() => { const readiness = evaluateCuttingReadiness(savedPlant); return (
{savedPlant.parentPlantName ? ( 🌿 Cutting from {savedPlant.parentPlantName} ) : null} {readiness.ready ? readiness.reason : 'Keep logging progress photos so Pixie can tell when this cutting has rooted and is ready to become a full plant.'} {readiness.ready ? ( { setCuttingConfirmation({ title: 'Graduate to a plant?', message: `This looks like it has rooted. Move ${savedPlant.name || savedPlant.commonName} from a cutting into a full plant? You can still take new cuttings from it afterwards.`, }); setGraduatePlant(savedPlant); })} > 🎓 Graduate to a Plant ) : null} ); })()} {/* Parent connection — persists from cutting through to full plant */} {isSavedPlant && savedPlant && savedPlant.parentPlantName && savedPlant.plantType !== 'cutting' ? ( 🌿 This plant was grown from a cutting of {savedPlant.parentPlantName} ) : null} {/* Phase 3 continuation — seed detail + graduation CTA (only for planted seeds) */} {isSavedPlant && savedPlant && isSeedProfile(savedPlant) && (() => { const readiness = evaluateSeedReadiness(savedPlant); const isTree = isTreeSeed(savedPlant); const nextStage = isTree ? 'sapling' : 'young plant'; const seed = savedPlant.seed; const stage = savedPlant.growthStage; // Life-chapter trimming (Bekky, 2026-09-08): "hide the past, keep the // future exciting." Germination/seedling-specific data (seed type, // storage, stratify/scarify, germination steps) is only relevant while // the seed is still a seedling — once it graduates to sapling/young // plant, that chapter is accomplished and fades out. Future-facing // data (time to grow, what it grows into, space needed) stays visible // as the exciting journey ahead. const isSeedling = stage === 'seedling'; const seedTypeLabel = seed?.seedType === 'recalcitrant' ? 'Recalcitrant — must be planted fresh. Dies if dried or refrigerated.' : seed?.seedType === 'orthodox' ? 'Orthodox — can be dried and stored for later planting.' : null; const comesTrueLabel = seed?.comesTrueFromSeed === true ? 'Yes — comes true from seed.' : seed?.comesTrueFromSeed === false ? 'No — does NOT come true from seed.' : null; return ( toggleSectionCollapsed('seed')} > {isTree ? 'This seed grows into a tree — it will pass through a sapling stage before becoming a full tree.' : 'This seed is growing into a young plant.'} {seed?.expectedOutcome ? ( What to expect: {seed.expectedOutcome} ) : null} {/* ── Seed profile (rich data from the scan, trimmed by life chapter) ── */} {seed ? (
{isSeedling && seedTypeLabel ? ( Seed type{seedTypeLabel} ) : null} {isSeedling && comesTrueLabel ? ( Comes true from seed?{comesTrueLabel} ) : null} {isSeedling && seed.comesTrueNote ? ( Note{seed.comesTrueNote} ) : null} {isSeedling && seed.canSaveSeed !== undefined ? ( Can you save this seed?{seed.canSaveSeed ? 'Yes' : 'No'} ) : null} {isSeedling && seed.savingMethod ? ( How to save{seed.savingMethod} ) : null} {isSeedling && seed.storageMethod ? ( How to store{seed.storageMethod} ) : null} {isSeedling && seed.storageDuration ? ( Stays viable{seed.storageDuration} ) : null} {isSeedling && seed.stratification && seed.stratification.needed ? ( Stratification{[seed.stratification.method, seed.stratification.duration].filter(Boolean).join(' — ')} ) : null} {isSeedling && seed.scarification && seed.scarification.needed ? ( Scarification{seed.scarification.method} ) : null} {isSeedling && seed.plantingDepth ? ( Planting depth{seed.plantingDepth} ) : null} {isSeedling && seed.lightRequirement ? ( Light requirement{seed.lightRequirement === 'light' ? 'Light' : seed.lightRequirement === 'dark' ? 'Dark' : 'Either light or dark'} ) : null} {isSeedling && seed.temperatureRange ? ( Temperature{seed.temperatureRange} ) : null} {isSeedling && seed.moisture ? ( Moisture{seed.moisture} ) : null} {isSeedling && seed.daysToGerminate ? ( Days to germinate{seed.daysToGerminate} ) : null} {isSeedling && seed.germinationRate ? ( Germination rate{seed.germinationRate} ) : null} {isSeedling && seed.germinationSteps && seed.germinationSteps.length ? ( Germination steps {seed.germinationSteps.map((step, i) => ( • {step} ))} ) : null} {isSeedling && seed.propagationMethods && seed.propagationMethods.length ? ( Seed propagation {seed.propagationMethods.map((m, i) => ( • {m.method} ({m.difficulty}) — {m.timeframe} ))} ) : null} {/* Future-facing data — always visible (the exciting journey ahead) */} {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.spaceNeeded ? ( Space needed{seed.spaceNeeded} ) : null} ) : null} {readiness.ready ? readiness.reason : `Keep logging progress photos so Pixie can tell when this seed is ready to become a ${nextStage}.`} {readiness.ready ? ( { setSeedGraduationConfirm({ title: isTree ? 'Graduate to a sapling?' : 'Graduate to a young plant?', message: `This seed looks ready to become a ${nextStage}. Move ${savedPlant.name || savedPlant.commonName} forward?`, }); setGraduateSeedPlant(savedPlant); })} > 🎓 Graduate to a {isTree ? 'Sapling' : 'Young Plant'} ) : null} ); })()} {/* Propagation card — at-a-glance propagation info. Hidden for seed profiles while they're still seedlings (Bekky, 2026-09-08): a seed isn't mature enough to be split/propagated yet, so the tile shouldn't even ask the question. Shows once it graduates (sapling/young plant). */} {isSavedPlant && savedPlant?.propagationInfo && !isSeedProfile(savedPlant) ? ( toggleSectionCollapsed('propagation')} > {savedPlant.propagationInfo.canPropagate && filterCuttingMethods(savedPlant.propagationInfo.methods).length > 0 ? ( <> {filterCuttingMethods(savedPlant.propagationInfo.methods).slice(0, 3).map(m => ( {ROOTING_METHOD_ICONS[m.method] || '🌱'} {cuttingMethodLabel(m.method)} · {m.difficulty} ))} ) : savedPlant.propagationInfo.canPropagate ? ( ) : ( )} {savedPlant.propagationInfo.canPropagate && savedPlant.plantType !== 'cutting' ? ( { const plant = savedPlant; setCuttingPlantName(getSavedPlantDisplayName(plant)); setCuttingLoading(true); setShowCuttingModal(true); // EPHEMERAL weather-enriched guidance (Bekky, 2026-08-20): // regenerate per-season, never persist. The plant detail keeps // stable species-facts; this card gets the season-aware version. const indoor = plant.plantType === 'houseplant' || plant.locationContext === 'indoor'; const unknownPlacement = !indoor && (plant.locationContext === 'unsure' || plant.locationContext === 'public_park_trail' || plant.locationContext === 'public_spaces' || plant.locationContext === 'street_tree' || plant.locationContext === 'nursery_store'); const placementLine = indoor ? 'indoor plant (controlled climate)' : unknownPlacement ? 'placement unknown — cover BOTH indoor and outdoor guidance' : 'outdoor plant'; import('../services/plantEnrichment').then(mod => { const season = mod.currentSeason(); // Feed the real location + weekly climate into the live // context so the guidance is region-aware — WITHOUT this the // AI defaults to US-centric advice ("if you live outside // USDA zone...") even for a user in Vietnam (Bekky, // 2026-08-26). Best-effort: whichever parts are available. let liveContext = `season: ${season}, ${placementLine}`; const loc = approximateLocationContext; if (loc && loc.trim()) liveContext += `, gardener location: ${loc.trim()}`; const w = weatherSnapshot; 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; const climateBits = [ avgHigh != null ? `weekly avg high ${avgHigh.toFixed(0)}°C` : '', avgLow != null ? `weekly avg low ${avgLow.toFixed(0)}°C` : '', w.humidityPct != null ? `humidity ${w.humidityPct.toFixed(0)}%` : '', ].filter(Boolean).join(', '); if (climateBits) liveContext += `, ${climateBits}`; if (!indoor && !unknownPlacement) { const today = days[0]; const hot = avgHigh != null && avgHigh > 32 || (w.temperatureC != null && w.temperatureC >= 30); const cold = today && today.tempMinC != null && today.tempMinC < 4 || (avgLow != null && avgLow < 4); if (hot || cold) { liveContext += '. The weather is extreme right now — if propagating outdoors, advise bringing the cutting indoors to a sheltered spot.'; } } } mod.getSeasonalCuttingGuidance(plant.commonName, plant.scientificName, liveContext, carePreferences?.organicFirst).then(info => { setCuttingPropagationInfo(info); setCuttingLoading(false); }); }); })} > ✂️ Take A Cutting ) : null} ) : null} {/* Where I'm from (Bekky, 2026-09-08): shows the precise GPS location where the plant was scanned, with an Open in Google Maps link. Only shown when precise GPS was captured (gpsLocation set). */} {isSavedPlant && !isWishlistDetail && savedPlant?.gpsLocation ? (
Scanned at {savedPlant.gpsLocation.latitude.toFixed(4)}, {savedPlant.gpsLocation.longitude.toFixed(4)} { try { await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${savedPlant.gpsLocation!.latitude},${savedPlant.gpsLocation!.longitude}`); } catch { Alert.alert('Maps unavailable', 'Google Maps could not be opened right now.'); } })} > 📍 Open in Google Maps ) : null} {!isWishlistDetail && ( toggleSectionCollapsed('environment')} collapsedFooter={ <> {setupRoom ? ( { if (!selectedPlant) return; spaceReturnPlantRef.current = selectedPlant; setSelectedPlant(null); setGardenSection('spaces'); setGardenDetail({ kind: 'space', id: setupRoom.id }); setTimeout(() => detailScrollRef.current?.scrollTo({ y: 0, animated: false }), 40); })} > Go to {setupRoom.name} { if (selectedPlant?.kind === 'saved') { setPendingSpaceId(resolvePlantSpaceId(selectedPlant.plant) || null); } setOriginatingSection('environment'); setShowSpacePicker(true); })} > Change Space ) : ( { setPendingSpaceId(null); setShowSpacePicker(true); })} > Assign Space )} {/* Placement — shown in the COLLAPSED footer too, below the space buttons (Bekky, 2026-08-15: placement visible when expanded OR collapsed). */} {isSavedPlant && (savedPlant?.placementDescription || savedPlant?.placementProximity) ? ( { if (!isSavedPlant) return; setPlacementDescriptionInput(savedPlant?.placementDescription || ''); const stored = normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]); setPlacementPhotos(stored); setPlacementPhotoUri(stored[0]?.uri || savedPlant?.placementPhoto || null); setPlacementProximity(savedPlant?.placementProximity || {}); setNeighborPlantIds(savedPlant?.neighborPlantIds || []); setShowEditPlacement(true); })} > Placement {savedPlant?.placementDescription || 'Not added'} › ) : isSavedPlant ? ( { if (!isSavedPlant) return; // Hydrate from the saved plant — don't wipe saved proximity // just because there's no description (Bekky, 2026-08-23). setPlacementDescriptionInput(savedPlant?.placementDescription || ''); const stored = normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]); setPlacementPhotos(stored); setPlacementPhotoUri(stored[0]?.uri || savedPlant?.placementPhoto || null); setPlacementProximity(savedPlant?.placementProximity || {}); setNeighborPlantIds(savedPlant?.neighborPlantIds || []); setShowEditPlacement(true); })} > Add Placement ) : null} } > {environmentRows.map(([k, v]) => ( {k} {v} ))} {/* Space buttons — shown in BOTH collapsed and expanded states (Bekky, 2026-08-15). */} {setupRoom ? ( { if (!selectedPlant) return; spaceReturnPlantRef.current = selectedPlant; setSelectedPlant(null); setGardenSection('spaces'); setGardenDetail({ kind: 'space', id: setupRoom.id }); setTimeout(() => detailScrollRef.current?.scrollTo({ y: 0, animated: false }), 40); })} > Go to {setupRoom.name} { if (selectedPlant?.kind === 'saved') { setPendingSpaceId(resolvePlantSpaceId(selectedPlant.plant) || null); } setOriginatingSection('environment'); setShowSpacePicker(true); })} > Change Space ) : ( { setPendingSpaceId(null); setShowSpacePicker(true); })} > Assign Space )} {/* Placement rows — shown BELOW the space buttons (Bekky, 2026-08-15 reorder). */} {isSavedPlant && (savedPlant?.placementDescription || savedPlant?.placementPhoto || savedPlant?.placementProximity) ? ( <> { if (!isSavedPlant) return; setPlacementDescriptionInput(savedPlant?.placementDescription || ''); const stored = normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]); setPlacementPhotos(stored); setPlacementPhotoUri(stored[0]?.uri || savedPlant?.placementPhoto || null); setPlacementProximity(savedPlant?.placementProximity || {}); setNeighborPlantIds(savedPlant?.neighborPlantIds || []); setShowEditPlacement(true); })} > Placement {savedPlant?.placementDescription || 'Not added'} › {(savedPlant?.placementPhoto || savedPlant?.placementPhotos?.length) ? ( { if (!isSavedPlant) return; setPlacementDescriptionInput(savedPlant?.placementDescription || ''); const stored = normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]); setPlacementPhotos(stored); setPlacementPhotoUri(stored[0]?.uri || savedPlant?.placementPhoto || null); setPlacementProximity(savedPlant?.placementProximity || {}); setNeighborPlantIds(savedPlant?.neighborPlantIds || []); setShowEditPlacement(true); })} > Placement photo {Math.max(normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]).length, savedPlant?.placementPhoto ? 1 : 0)} photo{savedPlant?.placementPhoto || (savedPlant?.placementPhotos?.length ?? 0) !== 1 ? 's' : ''} › ) : null} ) : isSavedPlant ? ( Placement Not added { if (!isSavedPlant) return; // Hydrate from the saved plant — don't wipe saved proximity // just because there's no description (Bekky, 2026-08-23). setPlacementDescriptionInput(savedPlant?.placementDescription || ''); const stored = normalizeContextPhotos(savedPlant?.placementPhotos, [savedPlant?.placementPhoto]); setPlacementPhotos(stored); setPlacementPhotoUri(stored[0]?.uri || savedPlant?.placementPhoto || null); setPlacementProximity(savedPlant?.placementProximity || {}); setNeighborPlantIds(savedPlant?.neighborPlantIds || []); setShowEditPlacement(true); })} > Add Placement ) : null} {activeForm === 'space' ? ( { spaceFormYRef.current = event.nativeEvent.layout.y; scrollDetailToForm('space', event.nativeEvent.layout.y); }}> {gardenFormCard} ) : null} toggleSectionCollapsed('setup')} collapsedFooter={ <> { setOriginatingSection('setup'); openSetupForm(selectedPlantKey); scrollToSection(setupSectionRef, 80); })}> {selectedSetup ? 'Edit Setup' : 'Add setup information'} } > {setupRows.map(([k, v]) => ( {k} {v} ))} {selectedSetup?.notes ? {selectedSetup.notes} : null} { setOriginatingSection('setup'); openSetupForm(selectedPlantKey); scrollToSection(setupSectionRef, 80); })}> {selectedSetup ? 'Edit Setup' : 'Add setup information'} {isSavedPlant && manualRefreshWindowOpen && !savedPlant?.careSchedule ? ( After updating environment and setup, let Pixie prepare its care guidance 🪄 { refreshPlantIntelligence(); setRefreshCueVisible(false); })} > {refreshIntelligenceLoading ? ( ) : ( {refreshCueVisible ? '✨ Setup saved — refresh care guidance' : 'Save and refresh Pixie Intelligence'} )} ) : null} )} {activeForm === 'setup' ? ( { setupFormYRef.current = event.nativeEvent.layout.y; scrollDetailToForm('setup', event.nativeEvent.layout.y); }}> {gardenFormCard} ) : null} {/* SHARED CONTAINER (Bekky, 2026-09-04): the Container section — shows the roommates (plants sharing this pot) with a color-coded conflict note at the TOP (above the roommate list, above the care section — important that the user sees it). Red = bad pairing, amber = neutral, green = good pairing. Tap a roommate to jump to it. "Edit roommates" opens the SharedContainerModal. */} {isSavedPlant && savedPlant?.sharedContainerId ? ( toggleSectionCollapsed('container')} > {/* Color-coded conflict note (Bekky, 2026-09-04): one-time AI assessment of how well this plant's species pairs with its roommates. Red = bad, amber = neutral, green = good. */} {savedPlant.sharedContainerNote ? ( {savedPlant.sharedContainerNote.text} ) : null} This plant shares its pot with {roommatesForSelectedPlant.length} other {roommatesForSelectedPlant.length === 1 ? 'plant' : 'plants'}. They're watered and fed together. {roommatesForSelectedPlant.map(rm => ( openPlantDetail({ kind: 'saved', plant: rm }))} style={s.containerRoommateRow} > {rm.photoUri ? ( ) : ( )} {getSavedPlantDisplayName(rm)} ))} { setSharedContainerModalFor('detail'); setShowSharedContainerModal(true); })} > Edit roommates ) : null} {!isWishlistDetail && ( toggleSectionCollapsed('care_guidance')} > {savedPlant?.careSchedule && Array.isArray(savedPlant.careSchedule.scheduled) && savedPlant.careSchedule.scheduled.length > 0 ? ( <> {savedPlant.careSchedule.scheduled.map((item, i) => { const schedule = savedPlant.careSchedule!; const label = item.action === 'water' ? '💧 Water' : item.action === 'fertilize' ? '🌿 Fertilize' : '🔍 Inspect'; const overrideDays = schedule.overrides?.[item.action]; const cadence = (overrideDays ?? item.everyDays) <= 1 ? 'every day' : `every ${overrideDays ?? item.everyDays} days`; const g = schedule.guidance?.[item.action]; return ( {label} {cadence} setCadenceOverride({ action: item.action, currentDays: overrideDays ?? item.everyDays, currentLastDone: schedule.lastDone?.[item.action] }))} > {g ? ( toggleCareGuidanceCollapsed(item.action))} > {isCareGuidanceCollapsed(item.action) ? '▸' : '▾'} ) : null} {g && !isCareGuidanceCollapsed(item.action) ? ( {/* PRIMARY ACTION group — color-coded so the gardener can find "the good stuff" (how/when) at a glance: water = blue, fertilize = green. For fertilize the "By season" line sits WITH howMuch+bestWays (Bekky, 2026-08-26). */} {item.action === 'water' && (g.howMuch || g.bestWays) ? ( {g.howMuch ? How much: {g.howMuch} : null} {g.bestWays ? Best ways: {g.bestWays} : null} ) : null} {item.action === 'fertilize' && (g.howMuch || g.bestWays || (g as any).seasonal) ? ( {g.howMuch ? How much: {g.howMuch} : null} {g.bestWays ? Best ways: {g.bestWays} : null} {(g as any).seasonal ? By season: {(g as any).seasonal} : null} ) : null} {/* Inspect has no how-much/best-ways box — its guidance is the "Look for" + pest chips (all pink, the bad stuff). Show any inspect howMuch/bestWays as plain lines. */} {item.action === 'inspect' && (g.howMuch || g.bestWays) ? ( {g.howMuch ? How much: {g.howMuch} : null} {g.bestWays ? Best ways: {g.bestWays} : null} ) : null} {item.action === 'fertilize' && (g as any).fertilizerType ? Fertilizer: {(g as any).fertilizerType} : null} {item.action === 'fertilize' && (g as any).localProducts && (g as any).localProducts.length > 0 ? ( Popular local options: {(g as any).localProducts.map((p: any, pi: number) => ( • {p.name}{p.organic ? ' (organic)' : ''}{p.note ? ` — ${p.note}` : ''} ))} ) : null} {item.action === 'fertilize' && (g as any).searchKeywords ? ( Search for: {(g as any).searchKeywords} ) : null} {/* AVOID group — pink box, the "bad stuff" (Bekky, 2026-08-26). For inspect the avoid text + the tappable pest chips both live in this pink box. */} {(g.avoid || (item.action === 'inspect' && ((g as any).whatToLookFor || (Array.isArray((g as any).pests) && (g as any).pests.length > 0)))) ? ( {g.avoid ? Avoid: {g.avoid} : null} {item.action === 'inspect' && ((g as any).whatToLookFor || (Array.isArray((g as any).pests) && (g as any).pests.length > 0)) ? ( Look for: {(g as any).whatToLookFor ? { (g as any).whatToLookFor} : null} {Array.isArray((g as any).pests) && (g as any).pests.length > 0 ? ( {(g as any).pests.map((pest: string, pi: number) => ( { try { // Append "plant" to disambiguate terms like // "edema" (else Google returns human-medical // images) — Bekky, 2026-08-23. Doesn't restrict // to a specific plant, so common pests still // return plenty of results. const q = encodeURIComponent(`${pest} plant`); await Linking.openURL(`https://www.google.com/search?q=${q}&tbm=isch`); } catch (e) { /* ignore open errors */ } })} > {pest} ))} ) : null} ) : null} ) : null} ) : null} ); })} {savedPlant.careSchedule.notes ? {savedPlant.careSchedule.notes} : null} ) : ( Add your plant's environment and setup details first for the most tailored care guidance. )} )} {!isWishlistDetail && (
{compactKnownItems.length ? ( {compactKnownItems.map(item => ( ✓ {item.label} ))} ) : Pixie is ready to learn about this plant.} {plantIntelligence.isAllSet ? ( <> All set ✨ {plantIntelligence.nextBestAction} { setOriginatingSection('memory'); openPlantEventForm('progress_photo'); })} > Add Memory ) : ( <> What would help next {plantIntelligence.nextBestAction} {plantIntelligence.nextBestActionKey ? ( {plantIntelligence.nextBestActionComingSoon ? 'Coming Soon' : 'Open'} ) : null} )} )} {!isWishlistDetail && ( { progressSectionYRef.current = event.nativeEvent.layout.y; }}>
{selectedPlantEvents.length ? ( {selectedPlantEvents.slice(0, 6).map(event => { const isProgressPhoto = event.type === 'progress_photo' && event.photoUri; return ( openEditPlantEventForm(event))}> {event.photoUri ? ( setPlacementViewerUri(event.photoUri as string))}> ) : {getPlantEventTypeLabel(event.type).charAt(0)}} {formatRelativeDate(event.eventDate || event.createdAt)} {event.title} {event.title !== plantMemoryDefaultTitles[event.type as PlantMemoryEventType] && ( {getPlantEventTypeLabel(event.type)} )} {event.note ? {event.note} : null} ); })} ) : ( <> No memories yet. Add progress photos or milestones so Pixie can remember how this plant changes over time. )} {isSavedPlant ? ( { setOriginatingSection('memory'); openPlantEventForm('progress_photo'); })}> Add Memory showPhotoGuidance('progress'))} accessibilityRole="button" accessibilityLabel="How to take a good progress photo" > ? ) : null} {sectionMessages.progress ? ( {sectionMessages.progress.message} ) : null} )} {activeForm === 'event' ? gardenFormCard : null} {isSavedPlant && !isWishlistDetail ? ( openPlantForm(selectedPlant.plant, { scrollToForm: true }))}> Edit Plant ) : null} {isSavedPlant && isWishlistDetail && viewingWishlistItem ? ( {/* Where I'm from (Bekky, 2026-09-08): precise GPS where the plant was scanned, with an Open in Google Maps link. Precise only. */} {viewingWishlistItem.gpsLocation ? (
Scanned at {viewingWishlistItem.gpsLocation.latitude.toFixed(4)}, {viewingWishlistItem.gpsLocation.longitude.toFixed(4)} { try { await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${viewingWishlistItem.gpsLocation!.latitude},${viewingWishlistItem.gpsLocation!.longitude}`); } catch { Alert.alert('Maps unavailable', 'Google Maps could not be opened right now.'); } })} > 📍 Open in Google Maps ) : null} onSavePlantFromWishlist(viewingWishlistItem))}> Add to Garden {viewingWishlistItem.scanMode === 'seed' ? ( startGrowingFromWishlist(viewingWishlistItem))}> 🌿 Start Growing ) : null} {viewingWishlistItem.propagation?.canPropagate !== false ? ( addWishlistCutting(viewingWishlistItem))}> ✂️ Add a Cutting ) : null} { onRemoveWishlistItem(viewingWishlistItem.id); setSelectedPlant(null); viewingWishlistRef.current = false; setViewingWishlistItem(null); })}> Remove from Wishlist ) : null} {activeForm === 'plant' ? ( { plantFormYRef.current = event.nativeEvent.layout.y; scrollDetailToForm('plant', event.nativeEvent.layout.y); }}> {gardenFormCard} ) : null} {isSavedPlant ? (
Use a clear photo from Scan or your own plant photo for this plant card. {detailPlantImage.source ? ( { const src = detailPlantImage.source as { uri?: string } | number; if (typeof src === 'object' && src?.uri) setPlacementViewerUri(src.uri); })} > ) : null} pickGardenPhoto(uri => { const patch: Partial = { photoUri: uri, image: uri }; onUpdatePlant(selectedPlant.plant.id, patch); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === selectedPlant.plant.id ? { kind: 'saved', plant: { ...current.plant, ...patch } } : current); }))} > {detailPlantImage.source ? 'Change Photo' : 'Add Photo'} ) : null} {/* Phase 4 — Photos & Export (per-plant) — moved below profile photo, above Delete */} {isSavedPlant && savedPlant ? (
{ const evs = getAllSavedPlantEvents(savedPlant); const photos: GalleryPhoto[] = []; if (savedPlant.photoUri) photos.push({ uri: savedPlant.photoUri, caption: 'Profile' }); for (const ev of evs) { if (ev.photoUri) photos.push({ uri: ev.photoUri, caption: ev.title }); } setGalleryPhotos(photos); })} > 📷 View Photos { setExportQualityChoice({ kind: 'plant', plantId: savedPlant.id, plantName: savedPlant.name || savedPlant.commonName || 'plant' }); })} > {exportBusy ? : 📤 Export Plant} ) : null} {isSavedPlant ? ( <> confirmDeletePlant(selectedPlant.plant), haptic.warning)} accessibilityRole="button" accessibilityLabel={`Delete ${detailTitle}`} > Delete Plant {selectedPlant.plant.notes ? (
{selectedPlant.plant.notes} ) : null} ) : ( <> confirmDeleteSamplePlant(selectedPlant.plant), haptic.warning)} accessibilityRole="button" accessibilityLabel={`Delete ${detailTitle}`} > Delete Plant )} {deleteConfirmModal} {eventDeleteConfirmModal} {spacePickerModal} {idCorrectionModal} {editPlacementModal} { setShowCuttingModal(false); if (!isSavedPlant || !selectedPlant) return; const plant = selectedPlant.plant; const now = new Date().toISOString(); const cuttingProfile: SavedPlantProfile = { ...plant, id: `plant_scan_${Date.now()}`, name: `${plant.name || plant.commonName} Cutting`, plantType: 'cutting', growthStage: 'cutting', rootingMedium: method, parentPlantId: plant.id, parentPlantName: plant.name || plant.commonName, spaceId: undefined, assignedSpaceId: undefined, events: [], plantEvents: [], setupCompleted: false, createdAt: now, updatedAt: now, source: 'manual_garden', }; onSavePlant(cuttingProfile); setCuttingConfirmation({ title: 'Cutting added!', message: `${cuttingProfile.name} has been added to your Garden.`, }); }} onClose={() => setShowCuttingModal(false)} /> setShowRepotModal(false)} onConfirm={() => { setShowRepotModal(false); if (!isSavedPlant || !selectedPlant) return; const plant = selectedPlant.plant; const existingEvents = getAllSavedPlantEvents(plant); const now = new Date().toISOString(); const repotEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(plant.id), createdAt: now, eventDate: now, type: 'repotting', eventCategory: 'action', title: 'Repotted', note: 'Repotted this plant.', source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'not_applicable', }; const nextEvents = [repotEvent, ...existingEvents].sort( (a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime() ); onUpdatePlant(plant.id, { events: nextEvents, plantEvents: nextEvents, updatedAt: now }); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plant.id ? { kind: 'saved', plant: { ...current.plant, events: nextEvents, plantEvents: nextEvents } } : current); // Standalone repot (Path A) — gentle reminder to come back and // update setup / snap a memory photo when done (Bekky, 2026-08-15). setRepotReminder({ title: 'Repotted!', message: `${getSavedPlantDisplayName(plant)} has been marked as repotted. When you're done, don't forget to update its setup or snap a memory photo.`, }); }} onTakeMemoryPhoto={() => { setShowRepotModal(false); setOriginatingSection('memory'); openPlantEventForm('progress_photo', true); }} onUpdateSetupPhoto={() => { // One-and-done (Path B): open the dedicated repot setup modal. // Available immediately — no need to wait for guidance to fetch. setShowRepotModal(false); setShowRepotSetupModal(true); }} /> { if (!isSavedPlant || !selectedPlant) return null; const spaceId = resolvePlantSpaceId(selectedPlant.plant); return spaceId ? (rooms.find(room => room.id === spaceId)?.spaceType ?? null) : null; })()} initialSetup={(() => { if (!isSavedPlant || !selectedPlant) return null; const setup = setupByPlantId.get(savedGardenKey(selectedPlant.plant.id)); return setup ? { potType: setup.potType, potSize: setup.potSize, drainage: setup.drainage, mediumTypes: setup.mediumTypes, customMedium: setup.customMedium, customMediumComponents: setup.customMediumComponents, setupPhotoUri: setup.setupPhotoUri, setupPhotos: setup.setupPhotos, } : null; })()} saving={repotSetupSaving} onBack={() => { setShowRepotSetupModal(false); setShowRepotModal(true); }} onCancel={() => setShowRepotSetupModal(false)} onSaveAndRepot={saveRepotSetup} pickedPhoto={repotSetupPhotoRef.current} onPickPhoto={() => { if (!isSavedPlant || !selectedPlant) return; openPhotoPickerWithGuidance('setup', (uri, extra) => { // Add the picked photo to the repot setup modal's photo state. // We store it in a ref so the modal can read it on next render. repotSetupPhotoRef.current = makeContextPhoto(uri, undefined, extra); setRepotSetupPhotoTick(t => t + 1); }); }} /> {/* SHARED CONTAINER (Bekky, 2026-09-04): pick roommates for a shared pot. Opens from the setup form's "Shared container" chip and from the plant detail's Container section. Writes a sharedContainerId to every selected plant so they all share the same id (symmetric). */} setShowSharedContainerModal(false)} /> {/* DELETE-ROOMMATE (Bekky, 2026-09-04): when deleting a plant that has a roommate, ask what happened to the roommate (3 exhaustive outcomes). */} setDeleteRoommateTarget(null)} buttons={[ { text: 'Delete it too', style: 'destructive', onPress: () => { if (deleteRoommateTarget) handleDeleteRoommateOutcome('delete_both'); } }, { text: 'Make it standalone', style: 'default', onPress: () => { if (deleteRoommateTarget) handleDeleteRoommateOutcome('standalone'); } }, { text: 'I repotted it', style: 'default', onPress: () => { if (deleteRoommateTarget) handleDeleteRoommateOutcome('repotted'); } }, ]} /> setRepotReminder(null)} buttons={[ { text: 'Got it', style: 'default', onPress: () => setRepotReminder(null) }, ]} /> { setGraduatePlant(null); setCuttingConfirmation(null); } }, { text: '🎓 Graduate', style: 'default', onPress: () => { const plant = graduatePlant; setGraduatePlant(null); setCuttingConfirmation(null); if (!plant) return; const graduated = graduateCuttingProfile(plant); onUpdatePlant(plant.id, { plantType: graduated.plantType, growthStage: graduated.growthStage, updatedAt: graduated.updatedAt, } as Partial); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plant.id ? { kind: 'saved', plant: { ...current.plant, ...graduated } } : current); } }, ] : [ ...(cuttingConfirmation?.onAction && cuttingConfirmation?.actionText ? [{ text: cuttingConfirmation.actionText, style: 'default' as const, onPress: () => { const a = cuttingConfirmation.onAction; setCuttingConfirmation(null); a?.(); } }] : []), { text: 'Got it', style: 'default', onPress: () => setCuttingConfirmation(null) }, ] } onRequestClose={() => { setGraduatePlant(null); setCuttingConfirmation(null); }} /> {/* Phase 3 continuation — seed graduation confirm (seed → sapling/young plant) */} { setGraduateSeedPlant(null); setSeedGraduationConfirm(null); } }, { text: '🎓 Graduate', style: 'default', onPress: () => { const plant = graduateSeedPlant; setGraduateSeedPlant(null); setSeedGraduationConfirm(null); if (!plant) return; const graduated = graduateSeedProfile(plant); onUpdatePlant(plant.id, { growthStage: graduated.growthStage, updatedAt: graduated.updatedAt, } as Partial); setSelectedPlant(current => current?.kind === 'saved' && current.plant.id === plant.id ? { kind: 'saved', plant: { ...current.plant, ...graduated } } : current); } }, ] : [ { text: 'Got it', style: 'default', onPress: () => setSeedGraduationConfirm(null) }, ] } onRequestClose={() => { setGraduateSeedPlant(null); setSeedGraduationConfirm(null); }} /> setDuplicateConfirmation(null) }, ]} onRequestClose={() => setDuplicateConfirmation(null)} /> { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'compressed'); } }, { text: 'Full quality', style: 'default', onPress: () => { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'original'); } }, { text: 'Cancel', style: 'cancel', onPress: () => setExportQualityChoice(null) }, ]} onRequestClose={() => setExportQualityChoice(null)} /> p.id === resolveOutcomeInv?.plantId)?.name || savedPlants?.find(p => p.id === resolveOutcomeInv?.plantId)?.commonName || resolveOutcomeInv?.title || '') : (resolveOutcomeInv?.title || '')} onConfirm={async (outcome) => { const inv = resolveOutcomeInv; if (!inv) return; try { if (inv.plantId && (outcome.successful.length > 0 || outcome.unsuccessful.length > 0)) { const now = new Date().toISOString(); for (const desc of outcome.successful) { await addIntervention(inv.plantId, { id: `intervention_${Date.now()}_${Math.round(Math.random() * 10000)}`, description: desc, outcome: 'successful', date: now, relatedEventId: inv.investigationId, }); } for (const desc of outcome.unsuccessful) { await addIntervention(inv.plantId, { id: `intervention_${Date.now()}_${Math.round(Math.random() * 10000)}`, description: desc, outcome: 'unsuccessful', date: now, relatedEventId: inv.investigationId, }); } } await updateInvestigationStatus(inv.investigationId, 'resolved'); getAllInvestigations().then(loaded => setInvestigations(loaded)).catch(() => {}); setResolveOutcomeInv(null); setSelectedInvestigation(current => current && current.investigationId === inv.investigationId ? { ...current, status: 'resolved' as const } : current); setCuttingConfirmation({ title: 'Case resolved!', message: 'Pixie has remembered what worked (and what didn\u2019t) for next time this plant has a problem.' }); } catch { setResolveOutcomeInv(null); setCuttingConfirmation({ title: 'Couldn\u2019t resolve', message: 'Something went wrong saving the outcome. Please try again.' }); } }} onClose={() => setResolveOutcomeInv(null)} /> { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void takeGardenPhoto(cb); }} onChoosePhoto={() => { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void chooseGardenPhotoFromLibrary(cb); }} onCancel={() => { setShowPhotoPicker(false); pendingPhotoCallbackRef.current = null; }} /> 0} plantName={selectedPlant?.kind === 'saved' ? (selectedPlant.plant.name || selectedPlant.plant.commonName || 'Plant') : 'Plant'} photos={galleryPhotos} onClose={() => setGalleryPhotos([])} onSaveToDevice={async (photo) => { try { const { shareExport } = await import('../services/dataExport'); await shareExport(photo.uri); } catch { setCuttingConfirmation({ title: 'Could not save', message: 'There was a problem sharing that photo.' }); } }} onSaveAll={async () => { try { const { shareExport } = await import('../services/dataExport'); const first = galleryPhotos[0]; if (first) await shareExport(first.uri); } catch { setCuttingConfirmation({ title: 'Could not save', message: 'There was a problem sharing the photos.' }); } }} /> {/* Full-screen photo viewer — tap any thumbnail to enlarge */} {placementViewerUri ? ( setPlacementViewerUri(null)}> setPlacementViewerUri(null)}> ) : null} {/* Photo delete confirm (placement / setup / environment) */} {photoDeleteConfirmModal} saveCadenceOverride(days, lastDone)} onUseDefault={() => saveCadenceOverride(null)} onClose={() => setCadenceOverride(null)} /> ); } return ( { if (openGardenDropdown) setOpenGardenDropdown(null); }} >
{GARDEN_SECTION_ORDER.map(section => ( { closeGardenForms(); if (gardenSection !== section) gardenSectionHistoryRef.current.push(gardenSection); setGardenSection(section); setGardenSearch(''); })} > {section === 'plants' ? 'Plants' : section === 'spaces' ? 'Spaces' : section === 'wishlist' ? 'Wishlist' : section === 'field' ? 'Field' : section === 'investigations' ? 'Cases' : 'Cases'} ))} {multiDeleteKeys !== null && gardenSection === 'plants' ? ( {multiDeleteKeys.size} selected Cancel setShowMultiDeleteConfirm(true), haptic.warning)} accessibilityRole="button" accessibilityLabel="Delete selected plants"> Delete ) : null} {gardenSection === 'wishlist' && wishlistDeleteKeys !== null ? ( {wishlistDeleteKeys.size} selected Cancel setShowWishlistDeleteConfirm(true), haptic.warning)} accessibilityRole="button" accessibilityLabel="Remove selected wishlist items"> Remove ) : null} {gardenSection === 'field' && fieldDeleteKeys !== null ? ( {fieldDeleteKeys.size} selected Cancel setShowFieldDeleteConfirm(true), haptic.warning)} accessibilityRole="button" accessibilityLabel="Remove selected field albums"> Remove ) : null} {gardenSection !== 'wishlist' && gardenSection !== 'field' ? ( setOpenGardenDropdown(openGardenDropdown === 'filter' ? null : 'filter'))}> Filter: {activeFilterLabel} setOpenGardenDropdown(openGardenDropdown === 'sort' ? null : 'sort'))}> Sort: {activeSortLabel} ) : null} {openGardenDropdown === 'filter' && gardenSection !== 'wishlist' ? ( {(gardenSection === 'plants' ? plantFilterOptions : [{ value: 'all', label: 'All' }]).map(option => ( { if (gardenSection === 'plants') { const v = option.value as PlantFilter; setPlantFilter(v); onGardenViewChange?.({ plantFilter: v, plantSort }); } setOpenGardenDropdown(null); })} > {option.label} ))} ) : null} {openGardenDropdown === 'sort' && gardenSection !== 'wishlist' ? ( {(gardenSection === 'plants' ? plantSortOptions : spaceSortOptions).map(option => ( { if (gardenSection === 'plants') { const v = option.value as PlantSort; setPlantSort(v); onGardenViewChange?.({ plantFilter, plantSort: v }); } if (gardenSection === 'spaces') setSpaceSort(option.value as SpaceSort); setOpenGardenDropdown(null); })} > {option.label} ))} ) : null} {activeForm === 'plant' ? ( scrollToGardenFormHeader(plantFormHeaderRef, 30)}> {gardenFormCard} ) : gardenFormCard} { const w = event.nativeEvent.layout.width; if (w > 0) { gardenPagerWidthRef.current = w; setGardenPagerWidth(w); // The pager unmounts while a case-detail (or plant/space/kit detail) // early-return is showing, and remounts fresh at offset 0 (Plants) // when you back out. gardenSection does NOT change in that path, so // the section-change effect never re-fires. Snap the pager to the // CURRENT section on the first layout after mount so back always // returns to the correct page (fixes the "Cases highlighted but // Plants shown" split). Only once per mount — never on later // re-layouts, which would fight a user swipe mid-scroll. if (pendingScrollToSectionRef.current) { const pending = pendingScrollToSectionRef.current; pendingScrollToSectionRef.current = null; scrollGardenPagerToSection(pending); } else if (!gardenPagerInitialSnapRef.current && !gardenPagerScrollingRef.current) { // Only snap if the pager isn't mid-scroll (M7) — otherwise a // programmatic snap can fight a user swipe that's in flight. gardenPagerInitialSnapRef.current = true; scrollGardenPagerToSection(gardenSection); } } }} onMomentumScrollEnd={event => { const w = gardenPagerWidthRef.current || gardenPagerWidth; const idx = Math.round(event.nativeEvent.contentOffset.x / (w || 1)); const section = GARDEN_SECTION_ORDER[idx]; if (section && section !== gardenSection) { closeGardenForms(); // Avoid pushing a duplicate/self entry (M8) — a back loop happens // when the same section is pushed twice in a row. const last = gardenSectionHistoryRef.current[gardenSectionHistoryRef.current.length - 1]; if (last !== gardenSection) gardenSectionHistoryRef.current.push(gardenSection); setGardenSection(section); setGardenSearch(''); } }} > {!gardenDataLoaded && activeForm !== 'plant' ? Gathering your garden... : null} {gardenDataLoaded && visibleGardenPlantItems.length === 0 && activeForm !== 'plant' ? : null} {/* Phase 3 (2026-09-07): Seed Bank tile pinned to the top of the Plants section. Tapping it opens the Seed Bank modal (guide + saved seeds). */} setSeedBankOpen(true))} accessibilityRole="button" accessibilityLabel="Open Seed Bank" > 🌱 Seed Bank {savedPlants.filter((p) => p.scanMode === 'seed').length} saved {plantSort === 'by_space' ? visibleGardenPlantGroups.map(section => ( {section.title} {section.spaces.map(group => ( {group.title} {group.items.map(renderGardenPlantTile)} ))} )) : gardenGroupSections.length > 0 ? gardenGroupSections.map(group => ( {group.title} {group.items.map(renderGardenPlantTile)} )) : visibleGardenPlantItems.map(renderGardenPlantTile)} {activeForm !== 'plant' ? openPlantForm(undefined, { scrollToForm: true }))} activeOpacity={0.84} accessibilityRole="button" accessibilityLabel="Add Plant"> Add Plant Create a new card : null} {activeForm !== 'plant' ? { setExportQualityChoice({ kind: 'journal' }); })} activeOpacity={0.84} accessibilityRole="button" accessibilityLabel="Export Plant Journal" > 📤 Export Journal Save your plant data : null} {activeForm !== 'plant' ? { try { const { pickAndImport } = await import('../services/dataImport'); const result = await pickAndImport(); if (result.success) { Alert.alert('Import Complete', `Restored ${result.plantCount ?? 0} plants and ${result.photoCount ?? 0} photos. Restart the app to see your data.`); } else if (result.error !== 'Import cancelled.') { Alert.alert('Import', result.error || 'Could not import. Please try again.'); } } catch { Alert.alert('Import', 'Could not import. Please try again.'); } })} activeOpacity={0.84} accessibilityRole="button" accessibilityLabel="Import Plant Journal" > 📥 Import Journal Restore from backup : null} {activeForm !== 'space' ? ( openSpaceForm())}> Add Space ) : null} {visibleRoomGroups.length ? visibleRoomGroups.map(group => ( {group.title} {group.rooms.map(room => { const assignedItems = getAssignedGardenItemsForSpace(room.id); return ( setGardenDetail({ kind: 'space', id: room.id })} /> ); })} )) : } {visibleWishlistItems.length ? ( {visibleWishlistItems.map(renderWishlistItemCard)} ) : } {/* Take a Walk / Take a Ride pinned buttons (Bekky, 2026-09-03). */} { if (activeExcursion?.mode === 'walk') handleStopExcursion(); else if (!activeExcursion) handleStartExcursion('walk'); })} > {activeExcursion?.mode === 'walk' ? `🥾 ${formatExcursionDuration(excursionElapsed)} · ${formatExcursionDistance(activeExcursion.distanceMeters)}` : '🥾 Take a Walk'} { if (activeExcursion?.mode === 'ride') handleStopExcursion(); else if (!activeExcursion) handleStartExcursion('ride'); })} > {activeExcursion?.mode === 'ride' ? `🚴 ${formatExcursionDuration(excursionElapsed)} · ${formatExcursionDistance(activeExcursion.distanceMeters)}` : '🚴 Take a Ride'} {/* Diagnostic line (Bekky, 2026-09-03): shows live GPS points + distance so we can see if the background task is receiving locations without needing ADB. Remove once the distance bug is confirmed fixed. */} {activeExcursion ? ( GPS points: {activeExcursion.points.length} · dist: {activeExcursion.distanceMeters.toFixed(1)}m ) : null} {fieldAlbums.length ? ( {fieldAlbums.map(renderFieldAlbumCard)} ) : } {investigations.length > 0 ? investigations.map(inv => { const plantName = inv.plantId ? (savedPlants?.find(p => p.id === inv.plantId)?.name || savedPlants?.find(p => p.id === inv.plantId)?.commonName) : null; const statusColors: Record = { open: '#FF9800', monitoring: '#2196F3', resolved: '#4CAF50', archived: '#9E9E9E', }; const primaryPhoto = inv.photos.length > 0 ? inv.photos[0] : null; return ( setSelectedInvestigation(inv))} > {primaryPhoto ? ( ) : ( 🔍 )} {inv.title} {plantName ? ( 🌿 {plantName} ) : null} {new Date(inv.createdAt).toLocaleDateString()} · {(inv.points?.length || 1)} visit{(inv.points?.length || 1) !== 1 ? 's' : ''} {inv.status} ); }) : ( )} {deleteConfirmModal} {multiDeleteConfirmModal} {wishlistDeleteConfirmModal} { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void takeGardenPhoto(cb); }} onChoosePhoto={() => { setShowPhotoPicker(false); const cb = pendingPhotoCallbackRef.current; pendingPhotoCallbackRef.current = null; if (cb) void chooseGardenPhotoFromLibrary(cb); }} onCancel={() => { setShowPhotoPicker(false); pendingPhotoCallbackRef.current = null; }} /> { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'compressed'); } }, { text: 'Full quality', style: 'default', onPress: () => { const c = exportQualityChoice; setExportQualityChoice(null); if (c) runExport(c.kind, c.plantId, c.plantName, 'original'); } }, { text: 'Cancel', style: 'cancel', onPress: () => setExportQualityChoice(null) }, ]} onRequestClose={() => setExportQualityChoice(null)} /> {photoDeleteConfirmModal} { const a = cuttingConfirmation.onAction; setCuttingConfirmation(null); a?.(); } }] : []), { text: 'OK', style: 'default', onPress: () => setCuttingConfirmation(null) }, ]} onRequestClose={() => setCuttingConfirmation(null)} /> saveCadenceOverride(days, lastDone)} onUseDefault={() => saveCadenceOverride(null)} onClose={() => setCadenceOverride(null)} /> {/* Take a Walk / Take a Ride — GPS + battery consent (Bekky, 2026-09-03). */} { const m = excursionConsent?.mode; if (m) confirmStartExcursion(m); } }, { text: 'Not now', style: 'cancel', onPress: () => setExcursionConsent(null) }, ]} onRequestClose={() => setExcursionConsent(null)} /> {/* Confirm-to-exit: end the excursion + generate the map. */} confirmStopExcursion() }, { text: 'Keep going', style: 'cancel', onPress: () => setExcursionExitConfirm(false) }, ]} onRequestClose={() => setExcursionExitConfirm(false)} /> {/* Interactive map viewer for an excursion album. */} {viewingExcursionMap?.placeLabel || 'Trip map'} {viewingExcursionMap ? ( ) : null} {/* Phase 3 (2026-09-07): Seed Bank modal — saved-seed collection. */} setSeedBankOpen(false)} savedPlants={savedPlants} onPlantSeed={(plant) => { setSeedBankOpen(false); setPlantingSeed(plant); }} onDeleteSeed={(plant) => { setSeedBankOpen(false); onDeletePlant(plant.id); }} /> {/* Phase 3 (2026-09-07): Plant Seed modal — pick a method, add to Garden. */} setPlantingSeed(null)} onPlant={(method) => { const plant = plantingSeed; setPlantingSeed(null); if (!plant) return; const now = new Date().toISOString(); const seedProfile: SavedPlantProfile = { ...plant, id: `plant_seed_${Date.now()}`, name: `${plant.commonName || plant.name} (seed)`, plantType: 'garden_crop', growthStage: 'seedling', spaceId: undefined, assignedSpaceId: undefined, events: [], plantEvents: [], setupCompleted: false, createdAt: now, updatedAt: now, source: 'manual_garden', }; onSavePlant(seedProfile); setSeedBankOpen(false); }} /> ); } const s = StyleSheet.create({ spaceDetailArtwork: { width: 196, height: 196, borderRadius: 22, alignSelf: 'center' }, gardenGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 12, paddingBottom: 12 }, gardenPlantSpaceSubgroup: { width: '100%', marginBottom: 12 }, gardenSpaceGroup: { width: '100%', marginBottom: 0 }, gardenSegmentActive: { backgroundColor: green, borderColor: green }, deleteNoButton: { flex: 1, minHeight: 48, borderRadius: 24, borderWidth: 1.5, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center' }, plantIntelligenceNextButton: { alignSelf: 'flex-start', minHeight: 34, borderRadius: 17, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 13, marginTop: 8 }, gardenTilePlantPlaceholderText: { color: '#6B7E6E', fontSize: 11, lineHeight: 14, fontWeight: '700' }, trustNote: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 8 }, invDetailFadedText: { opacity: 0.6 }, assignPlantState: { minWidth: 72, minHeight: 30, borderRadius: 15, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, problemScanListItem: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', marginLeft: 8, marginBottom: 3 }, gardenPlantTileMeta: { color: '#6B7E6E', fontSize: 10, lineHeight: 13, fontWeight: '400', marginTop: 2, textAlign: 'center' }, invDetailScroll: { flex: 1 }, deleteNoButtonText: { color: dark, fontSize: 15, fontWeight: '900' }, contextPhotoInput: { minHeight: 38, borderRadius: 14, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 10, color: '#2F302A', fontSize: 12, fontWeight: '800' }, assignPlantRow: { flexDirection: 'row', alignItems: 'center', gap: 9, minHeight: 54, paddingVertical: 7, paddingHorizontal: 8, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(228,220,196,0.82)', backgroundColor: '#FFFDF7', marginBottom: 7 }, contextPhotoAddButton: { flex: 0, marginTop: 2, marginBottom: 10 }, formCancelButton: { alignSelf: 'flex-start', minHeight: 36, paddingHorizontal: 14, borderRadius: 18, borderWidth: 1, borderColor: '#C8B9AE', backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }, formCancelButtonText: { color: '#7A6A5E', fontSize: 14, fontWeight: '600', textAlign: 'center' }, formCardHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(191,217,180,0.95)', paddingBottom: 10, marginBottom: 10 }, formCardHeaderTitle: { flex: 1, minWidth: 0 }, formCardHeaderX: { flexShrink: 0, minHeight: 30, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 14, marginLeft: 8 }, formCardHeaderXText: { color: '#8E2F1E', fontSize: 13, fontWeight: '900' }, gardenSpaceGroupGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 12 }, status: { backgroundColor: '#DDF4CF', borderRadius: 14, paddingHorizontal: 15, paddingVertical: 8, color: green, fontWeight: '900' }, assignPlantStateText: { color: dark, fontSize: 11, fontWeight: '900' }, content: { paddingHorizontal: 18, paddingTop: 8, paddingBottom: 160 }, wishlistActionRow: { flexDirection: 'row', gap: 8, marginTop: 4 }, deletePlantCard: { padding: 10 }, gardenTileMeta: { color: '#78694B', fontSize: 10, lineHeight: 13, fontWeight: '700', marginTop: 2, textAlign: 'center' }, gardenDropdownOption: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 13, borderBottomWidth: 1, borderBottomColor: 'rgba(191,217,180,0.55)' }, invCardRow: { flexDirection: 'row', alignItems: 'center' }, deletePlantButtonText: { color: '#8E3D2F', fontSize: 15, fontWeight: '900' }, deleteConfirmBody: { color: '#5D5A4E', fontSize: 13, lineHeight: 18, fontWeight: '800', textAlign: 'center', marginTop: 8 }, deleteYesButton: { flex: 1, minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center' }, setupReadonlyText: { color: dark, fontSize: 13, lineHeight: 18, fontWeight: '900' }, assignPlantStateSelected: { backgroundColor: green, borderColor: green }, plantDetailHeroPlaceholderText: { color: '#6B7E6E', fontSize: 14, lineHeight: 18, fontWeight: '900' }, wishlistPrimaryAction: { flex: 1, minHeight: 40, borderRadius: 20, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, invDetailSpacer: { width: 60 }, gardenPlantTileTitle: { maxWidth: '100%', color: '#1E5E3A', fontSize: 12, lineHeight: 15, fontWeight: '700', textAlign: 'center', flexShrink: 1 }, health: { flexDirection: 'row', alignItems: 'center', gap: 10 }, gardenSearch: { height: 44, borderRadius: 22, backgroundColor: 'rgba(255,253,247,0.96)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', flexDirection: 'row', alignItems: 'center', gap: 9, paddingHorizontal: 14, marginBottom: 8 }, invDetailTimelineContent: { flex: 1 }, setupButtonText: { color: '#fff', fontSize: 15, fontWeight: '900', textAlign: 'center' }, // Attention cue on the "Save and refresh" button after a setup/environment/ // placement save — a soft green glow + gentle pulse so the user notices // the refresh is the next step (Bekky, 2026-08-19, green palette version). setupButtonCue: { backgroundColor: '#3E7A33', borderWidth: 2, borderColor: '#8FD98F', shadowColor: '#8FD98F', shadowOpacity: 0.75, shadowRadius: 12, elevation: 8 }, setupButtonCueText: { fontSize: 16 }, photoViewerOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)', justifyContent: 'center', alignItems: 'center' }, photoViewerImage: { width: '100%', height: '100%' }, photoViewerClose: { position: 'absolute', top: 48, right: 18, width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.92)', alignItems: 'center', justifyContent: 'center', zIndex: 10 }, photoViewerCloseText: { color: '#000', fontSize: 20, fontWeight: '900', lineHeight: 22 }, statKey: { fontWeight: '800', color: dark, fontSize: 13 }, setupButton: { flex: 1, minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginBottom: 8 }, // Lasso that ties Environment + Setup + refresh-Pixie together (Bekky, 2026-08-13). plantContextLasso: { borderWidth: 1.6, borderColor: 'rgba(85,116,94,0.55)', borderRadius: 14, backgroundColor: 'rgba(253,247,235,0.35)', paddingHorizontal: 10, paddingVertical: 8, marginBottom: 10, }, careCompactEmpty: { color: '#6A654F', fontSize: 12, lineHeight: 16, fontWeight: '600', paddingTop: 3, paddingBottom: 4 }, wishlistActionButton: { flex: 1, minHeight: 36, borderRadius: 18, backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 }, wishlistActionButtonText: { color: dark, fontSize: 11, fontWeight: '900' }, wishlistRemoveActionText: { color: '#8E3D2F', fontSize: 11, fontWeight: '900' }, plantHistoryCopy: { flex: 1, minWidth: 0 }, formSecondaryButtonText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, memoryCategoryCard: { borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', padding: 14, marginBottom: 8 }, invDetailSymptomRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, invPointHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }, invPointTitleGroup: { flex: 1, flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap', gap: 8 }, invPointTitle: { color: '#2D4A2D', fontSize: 14, fontWeight: '900' }, invPointDate: { color: '#8A8571', fontSize: 11, fontWeight: '600' }, invPointChevron: { fontSize: 22, color: green, fontWeight: '900', lineHeight: 24, transform: [{ rotate: '0deg' }], paddingLeft: 8 }, invPointChevronCollapsed: { transform: [{ rotate: '-90deg' }] }, plantIntelligenceNextButtonDisabled: { backgroundColor: '#F7F1E4' }, problemScanListText: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', flex: 1 }, bar: { height: 7, backgroundColor: '#E5EBCF', borderRadius: 99, overflow: 'hidden', flex: 1, marginTop: 5 }, assignPlantRowSelected: { borderColor: green, backgroundColor: '#EEF8DE' }, statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8, borderTopWidth: 1, borderColor: line, gap: 12 }, careGuidanceBlock: { marginBottom: 4 }, careGuidanceBody: { paddingBottom: 10, paddingHorizontal: 2 }, careGuidanceLine: { fontSize: 15, lineHeight: 21, color: '#2F302A', fontWeight: '500', marginTop: 4 }, careGuidanceLabel: { fontWeight: '800', color: '#1E5E3A' }, // Color-coded care boxes (Bekky, 2026-08-26) — full-background rectangles so // the gardener can skim the guidance at a glance and find the section they // need: water = blue (good/how), fertilize = green (good/how/by-season), // avoid + inspect "look for" = pink (the bad stuff). careBlueGroup: { backgroundColor: '#E7F1FA', borderRadius: 16, borderWidth: 1, borderColor: '#C4DDF0', paddingHorizontal: 14, paddingVertical: 10, marginTop: 6, }, careGreenGroup: { backgroundColor: '#E8F5D3', borderRadius: 16, borderWidth: 1, borderColor: '#D3E7B8', paddingHorizontal: 14, paddingVertical: 10, marginTop: 6, }, carePinkGroup: { backgroundColor: '#FBE9EC', borderRadius: 16, borderWidth: 1, borderColor: '#F2CCD3', paddingHorizontal: 14, paddingVertical: 10, marginTop: 6, }, careGuidanceEditButton: { width: 30, height: 30, borderRadius: 8, borderWidth: 1.5, borderColor: '#2E6B3F', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, careGuidanceEditHint: { fontSize: 15, color: '#2E6B3F', fontWeight: '800', lineHeight: 18 }, // Chevron toggle — same box size as the pencil (30×30) so they sit evenly, // but the glyph is bigger so the little triangle reads clearly (Bekky, 2026-08-23). careGuidanceChevronButton: { width: 30, height: 30, borderRadius: 8, borderWidth: 1.5, borderColor: '#2E6B3F', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, careGuidanceChevronGlyph: { fontSize: 19, color: '#2E6B3F', fontWeight: '900', lineHeight: 22 }, // Tappable pest/disease link chips → Google Image Search (Bekky, 2026-08-23). // Now sits inside the pink inspect box, so chips use a pink-tinted fill that // reads as tappable against it. pestLinkChip: { minHeight: 32, borderRadius: 999, borderWidth: 1, borderColor: 'rgba(170,84,101,0.55)', backgroundColor: '#FCE3E9', paddingHorizontal: 12, paddingVertical: 5, alignItems: 'center', justifyContent: 'center' }, pestLinkText: { color: '#8E3D4A', fontSize: 13, fontWeight: '700' }, // "Look for:" prose sits inside the pink inspect avoid box (Bekky, 2026-08-26). lookForProse: { color: '#7A3B48', fontSize: 13, lineHeight: 19 }, plantHistoryMeta: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '800', marginTop: 1 }, barFill: { height: '100%', backgroundColor: brand.colors.freshSprout, borderRadius: 99 }, addPlantPlusHorizontal: { position: 'absolute', width: 20, height: 2.5, borderRadius: 1.25, backgroundColor: '#fff' }, invCardCopy: { flex: 1, marginLeft: 12 }, gardenTile: { width: '31%', height: 152, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 6, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3, marginBottom: 12 }, seedBankTile: { width: '100%', minHeight: 84, borderRadius: 16, borderWidth: 1.5, borderColor: green, backgroundColor: '#F0F7EA', padding: 14, flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3 }, seedBankTileEmoji: { fontSize: 30 }, seedBankTileTitle: { color: dark, fontSize: 16, fontWeight: '900' }, seedBankTileSub: { color: '#6B7E6E', fontSize: 12, fontWeight: '700', marginTop: 2 }, contextPhotoFields: { flex: 1, minWidth: 0 }, gardenSegmentText: { color: dark, fontSize: 13, fontWeight: '900' }, goalIcon: { width: 24, height: 24, flexShrink: 0 }, assignPlantStateTextSelected: { color: '#fff' }, setupReadonlyContext: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', padding: 10, marginTop: 3, marginBottom: 8 }, invDetailSafe: { flex: 1 }, rowCard: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 7, minHeight: 45, borderTopWidth: 1, borderColor: 'rgba(228,220,196,0.82)' }, deletePlantButton: { minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center' }, gardenSegment: { flex: 1, minHeight: 42, borderRadius: 21, borderWidth: 1, borderColor: line, backgroundColor: 'rgba(255,253,247,0.96)', alignItems: 'center', justifyContent: 'center' }, plantHistoryIconText: { color: green, fontSize: 14, fontWeight: '900' }, careSheetScrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(32,45,26,0.34)' }, invCardBadgeText: { fontSize: 10, color: '#fff', fontWeight: '900' }, invDetailPhotoGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, gardenSearchInput: { flex: 1, minWidth: 0, color: dark, fontSize: 13, fontWeight: '800', paddingVertical: 0 }, wishlistCard: { borderRadius: 22, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: 'rgba(255,253,247,0.96)', padding: 12, shadowColor: '#000', shadowOpacity: 0.07, shadowRadius: 8, elevation: 2 }, plantHistoryRelative: { color: '#7A704D', fontSize: 12, lineHeight: 16, fontWeight: '800' }, gardenPlantSpaceSubgroupTitle: { color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900', marginBottom: 8, paddingHorizontal: 3 }, invDetailPhotoLabel: { fontSize: 9, color: '#fff' }, resolveOutcomeButton: { minHeight: 50, borderRadius: 999, backgroundColor: dark, alignItems: 'center', justifyContent: 'center', marginTop: 12, paddingHorizontal: 20 }, checkInPrimaryButton: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginTop: 12, paddingHorizontal: 20 }, checkInPrimaryText: { color: '#fff', fontSize: 15, fontWeight: '900' }, checkInSecondaryButton: { minHeight: 48, borderRadius: 999, borderWidth: 1.5, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', marginTop: 10, paddingHorizontal: 20 }, checkInSecondaryText: { color: dark, fontSize: 15, fontWeight: '800' }, resolveOutcomeButtonText: { color: '#fff', fontSize: 15, fontWeight: '900' }, invDeleteButton: { minHeight: 46, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', marginTop: 6, paddingHorizontal: 20 }, invDeleteButtonText: { color: '#8E2F1E', fontSize: 14, fontWeight: '900' }, graduateButton: { minHeight: 50, borderRadius: 999, backgroundColor: '#4C9A5F', alignItems: 'center', justifyContent: 'center', marginTop: 12, paddingHorizontal: 20 }, graduateButtonText: { color: '#fff', fontSize: 15, fontWeight: '900' }, parentTile: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: '#F0F7E8', borderRadius: 14, borderWidth: 1, borderColor: line, paddingHorizontal: 12, paddingVertical: 10, marginBottom: 10 }, parentTileIcon: { fontSize: 20 }, parentTileLabel: { fontSize: 11, fontWeight: '800', color: '#8A8571', textTransform: 'uppercase', letterSpacing: 0.4 }, parentTileName: { fontSize: 15, fontWeight: '800', color: dark, marginTop: 1 }, problemScanSectionTitle: { color: '#78694B', fontSize: 13, fontWeight: '900', marginBottom: 6, marginTop: 4 }, plantFormActions: { marginTop: 2 }, invDetailTimelineRow: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 8 }, searchIconImg: { width: 16, height: 16, flexShrink: 0 }, formSecondaryButton: { flex: 1, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, // SHARED CONTAINER (Bekky, 2026-09-04): Container section styles. containerNoteBox: { borderRadius: 12, borderWidth: 1.5, paddingHorizontal: 12, paddingVertical: 10, marginBottom: 10 }, containerNoteBoxBad: { borderColor: '#C0392B', backgroundColor: '#FDECEA' }, containerNoteBoxNeutral: { borderColor: '#D4A017', backgroundColor: '#FDF6E3' }, containerNoteBoxGood: { borderColor: '#2E6B3F', backgroundColor: '#E8F5D3' }, containerNoteText: { fontSize: 13, lineHeight: 19, fontWeight: '600' }, containerNoteTextBad: { color: '#8E2F1E' }, containerNoteTextNeutral: { color: '#7A5C00' }, containerNoteTextGood: { color: '#1E5E3A' }, containerRoommateRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, paddingHorizontal: 10, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6' }, containerRoommateThumb: { width: 34, height: 34, borderRadius: 17, backgroundColor: '#E8F0E4' }, containerRoommateName: { flex: 1, fontSize: 14, fontWeight: '700', color: '#2D4A2D' }, containerRoommateChevron: { fontSize: 20, color: '#7A704D', fontWeight: '700' }, memoryCategoryDesc: { color: '#7A704D', fontSize: 12, lineHeight: 16, fontWeight: '600', marginTop: 2 }, spaceStatus_empty: { color: '#75684D', backgroundColor: '#F4EFE2', borderColor: '#DED3B9' }, pixieKnownChips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, pixieKnownChip: { backgroundColor: '#F3F7E6', borderRadius: 8, borderWidth: 1, borderColor: '#D6E7BE', paddingHorizontal: 10, paddingVertical: 4 }, pixieKnownChipText: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '800' }, photoGuidanceHelp: { width: 26, height: 26, borderRadius: 13, borderWidth: 1.5, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', marginBottom: 8 }, photoGuidanceHelpText: { color: dark, fontSize: 15, fontWeight: '900', lineHeight: 18 }, mediumGroupLabel: { color: '#5B7553', fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.4, marginTop: 2, marginBottom: 6 }, invCardMetaWrap: { marginTop: 6 }, problemScanConfidenceLabel: { color: '#5B7553', fontSize: 12, fontWeight: '700' }, wishlistList: { gap: 9, paddingBottom: 10 }, // Wishlist tile grid (Bekky, 2026-08-25) — mirrors the garden plant tiles. wishlistGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 12, paddingBottom: 12 }, wishlistTile: { width: '31%', height: 152, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 6, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3, marginBottom: 12 }, wishlistTilePlantFrame: { width: '100%', height: 96, borderRadius: 10, overflow: 'hidden', backgroundColor: '#EDF5E4' }, wishlistTilePlant: { width: '100%', height: '100%' }, wishlistTilePlantPlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center' }, wishlistTilePlantPlaceholderText: { fontSize: 26 }, wishlistTileCopy: { flex: 1, width: '100%', minWidth: 0, justifyContent: 'center', alignItems: 'center', marginTop: 4 }, wishlistTileName: { fontSize: 12, textAlign: 'center' }, wishlistTileSci: { fontSize: 10, textAlign: 'center' }, wishlistRemoveButton: { minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#D98E8E', backgroundColor: '#FFF3F0', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 }, wishlistRemoveButtonText: { color: '#B4553B', fontSize: 15, fontWeight: '900' }, // Field Diary album gallery (Bekky, 2026-08-25) fieldGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 12, paddingBottom: 12 }, excursionButtonRow: { flexDirection: 'row', gap: 10, marginBottom: 14 }, excursionButton: { flex: 1, borderRadius: 999, borderWidth: 1, borderColor: 'rgba(46,107,63,0.5)', backgroundColor: 'rgba(255,253,247,0.9)', paddingVertical: 12, alignItems: 'center', justifyContent: 'center' }, excursionButtonActive: { backgroundColor: '#2E6B3F', borderColor: '#2E6B3F' }, excursionButtonDisabled: { opacity: 0.4 }, excursionButtonText: { color: '#2E6B3F', fontSize: 14, fontWeight: '600' }, excursionButtonTextActive: { color: '#FFFFFF' }, excursionDiag: { color: '#5A6B52', fontSize: 11, marginBottom: 10, textAlign: 'center' }, fieldAlbumTile: { width: '31%', height: 152, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 6, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3, marginBottom: 12 }, fieldAlbumPhotoFrame: { width: '100%', height: 96, borderRadius: 10, overflow: 'hidden', backgroundColor: '#E8F5D3' }, fieldAlbumPhoto: { width: '100%', height: '100%' }, fieldAlbumPhotoPlaceholder: { flex: 1, alignItems: 'center', justifyContent: 'center' }, fieldAlbumPhotoPlaceholderText: { fontSize: 26 }, fieldAlbumCopy: { flex: 1, width: '100%', minWidth: 0, justifyContent: 'center', alignItems: 'center', marginTop: 4 }, fieldAlbumName: { fontSize: 12, textAlign: 'center' }, fieldAlbumMeta: { color: '#7A704D', fontSize: 10, textAlign: 'center', fontWeight: '700', marginTop: 2 }, fieldAlbumDetailTitle: { color: dark, fontSize: 20, lineHeight: 26, fontWeight: '900', textAlign: 'center', marginBottom: 4 }, fieldAlbumDetailMeta: { color: '#7A704D', fontSize: 13, lineHeight: 18, fontWeight: '700', textAlign: 'center', marginBottom: 16 }, fieldEntryCard: { borderRadius: 20, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: 'rgba(255,253,247,0.96)', padding: 12, marginBottom: 12, shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 }, fieldEntryCardHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 10 }, fieldEntryThumb: { width: 56, height: 56, borderRadius: 10, backgroundColor: '#E8F5D3' }, fieldEntryName: { color: dark, fontSize: 15, lineHeight: 20, fontWeight: '900', flexShrink: 1 }, fieldEntrySci: { color: '#7A704D', fontSize: 12, lineHeight: 16, fontWeight: '700' }, fieldEntryMeta: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 2 }, fieldEntryPhotosRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 4 }, fieldEntryPhotoThumb: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden' }, fieldEntryPhoto: { width: '100%', height: '100%' }, // Walk finds as GARDEN-RICH photo tiles (Bekky, 2026-09-02): match the garden // grid tile look — photo frame that fills the tile width + centered name below. fieldEntryGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 12 }, fieldEntryTile: { width: '31%', borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 6, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 9, shadowOffset: { width: 0, height: 3 }, elevation: 3, marginBottom: 12 }, fieldEntryTilePhotoFrame: { width: '100%', height: 96, borderRadius: 10, overflow: 'hidden', backgroundColor: '#E8F5D3' }, excursionMapTile: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E8F5D3' }, excursionMapTileEmoji: { fontSize: 28 }, excursionMapTileText: { color: '#2E6B3F', fontSize: 12, fontWeight: '700', marginTop: 2 }, excursionMapTileMeta: { color: '#5A6B52', fontSize: 10, marginTop: 2 }, excursionMapOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 20 }, excursionMapCard: { backgroundColor: '#FFFDF6', borderRadius: 20, padding: 16, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 12, elevation: 8 }, excursionMapHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }, excursionMapTitle: { color: '#2E6B3F', fontSize: 17, fontWeight: '700', flex: 1 }, excursionMapClose: { width: 32, height: 32, borderRadius: 16, backgroundColor: 'rgba(46,107,63,0.12)', alignItems: 'center', justifyContent: 'center' }, excursionMapCloseText: { color: '#2E6B3F', fontSize: 16, fontWeight: '700' }, fieldEntryTilePhoto: { width: '100%', height: '100%' }, fieldEntryTilePhotoFallback: { flex: 1, alignItems: 'center', justifyContent: 'center' }, fieldEntryTileIcon: { fontSize: 26 }, fieldEntryTileCopy: { flex: 1, width: '100%', minWidth: 0, justifyContent: 'center', alignItems: 'center', marginTop: 4 }, fieldEntryTileName: { color: '#1E5E3A', fontSize: 12, lineHeight: 15, fontWeight: '700', textAlign: 'center' }, fieldEntryLink: { color: '#2E6B3F', fontSize: 13, lineHeight: 18, fontWeight: '700', marginTop: 6, textDecorationLine: 'underline' }, fieldMapsLink: { alignSelf: 'center', marginTop: 2, marginBottom: 12, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, borderWidth: 1, borderColor: 'rgba(46,107,63,0.5)', backgroundColor: 'rgba(255,253,247,0.9)' }, fieldMapsLinkText: { color: '#2E6B3F', fontSize: 13, fontWeight: '700' }, fieldLightboxOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }, fieldLightboxPhoto: { width: '100%', height: '80%', resizeMode: 'contain' }, fieldLightboxClose: { position: 'absolute', top: 44, right: 20, padding: 10, zIndex: 5 }, fieldLightboxCloseText: { color: '#fff', fontSize: 22, fontWeight: '900' }, fieldEntryActionRow: { flexDirection: 'row', gap: 8, marginTop: 10, flexWrap: 'wrap' }, fieldEntryRemoveButton: { minHeight: 40, borderRadius: 999, borderWidth: 1, borderColor: '#D98E8E', backgroundColor: '#FFF3F0', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, fieldEntryRemoveText: { color: '#B4553B', fontSize: 13, fontWeight: '900' }, fieldEntryShareButton: { minHeight: 40, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, fieldEntryShareText: { color: '#fff', fontSize: 13, fontWeight: '900' }, fieldEntryBackButton: { alignSelf: 'flex-start', minHeight: 40, borderRadius: 999, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18, marginBottom: 8 }, fieldEntryBackText: { color: dark, fontSize: 14, fontWeight: '900' }, spaceTypeGroupTitle: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '900', marginTop: 3, marginBottom: 5, textTransform: 'uppercase' }, contextPhotoItem: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', padding: 8, marginBottom: 8 }, gardenTileCopy: { flex: 1, width: '100%', minWidth: 0, justifyContent: 'center', alignItems: 'center', marginTop: 4 }, gardenEntityTitle: { marginTop: 0, textAlign: 'left' }, formSecondaryButtonDangerText: { color: '#8E3D2F', fontSize: 13, fontWeight: '900' }, plantPhotoControl: { marginTop: 6, marginBottom: 10 }, deleteConfirmBubble: { width: '100%', maxWidth: 360, borderRadius: 24, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: 'rgba(255,253,247,0.98)', padding: 18, shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 16, elevation: 12 }, // Multi-delete (Bekky 2026-08-21): red-minus badge on tiles + action bar. multiDeleteBadge: { position: 'absolute', top: -4, right: -4, width: 26, height: 26, borderRadius: 13, backgroundColor: '#D9534F', borderWidth: 2, borderColor: '#FFF', alignItems: 'center', justifyContent: 'center', zIndex: 3, elevation: 4 }, multiDeleteBadgeOn: { backgroundColor: '#C9302C' }, multiDeleteBadgeMinus: { color: '#FFF', fontSize: 16, fontWeight: '900', lineHeight: 18 }, multiDeleteBadgeCheck: { color: '#FFF', fontSize: 14, fontWeight: '900', lineHeight: 17 }, gardenTileSelected: { borderColor: '#D9534F', borderWidth: 2 }, multiDeleteBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 10, paddingHorizontal: 4, paddingVertical: 10, borderRadius: 16, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: 'rgba(255,227,219,0.75)' }, multiDeleteBarCount: { color: '#8E3D2F', fontSize: 14, fontWeight: '900', marginLeft: 12 }, multiDeleteBarActions: { flexDirection: 'row', alignItems: 'center', gap: 10 }, multiDeleteCancelButton: { minHeight: 40, borderRadius: 20, borderWidth: 1.5, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' }, multiDeleteCancelButtonText: { color: dark, fontSize: 14, fontWeight: '900' }, multiDeleteDoneButton: { minHeight: 40, borderRadius: 20, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#D9534F', paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' }, multiDeleteDoneButtonDisabled: { backgroundColor: '#E5B4AC', borderColor: '#E5B4AC' }, multiDeleteDoneButtonText: { color: '#FFF', fontSize: 14, fontWeight: '900' }, formButtonRow: { flexDirection: 'row', gap: 12, marginTop: 7, marginBottom: 8 }, gardenNotesInput: { minHeight: 70, paddingTop: 10, textAlignVertical: 'top' }, spaceDetailSummaryHeader: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }, // Care Groups listing on the space detail (Chunk A, Bekky 2026-08-29). spaceGroupActionLabel: { color: '#2E6B3F', fontSize: 13, fontWeight: '800', marginBottom: 4, marginTop: 4 }, spaceGroupRow: { paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: 'rgba(191,217,180,0.4)' }, spaceGroupName: { color: '#2E4B35', fontSize: 14, fontWeight: '800' }, spaceGroupMeta: { color: '#78694B', fontSize: 12, marginTop: 2, lineHeight: 16 }, fertilizerModalOverlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.55)', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, fertilizerModalCard: { width: '100%', maxWidth: 420, backgroundColor: '#FFFDF4', borderRadius: 20, borderWidth: 1, borderColor: line, padding: 20 }, fertilizerModalTitle: { color: dark, fontSize: 17, fontWeight: '900', textAlign: 'center', marginBottom: 10 }, fertilizerModalWarn: { color: '#2D4A2D', fontSize: 14, lineHeight: 20, textAlign: 'center', marginBottom: 14, fontWeight: '700' }, fertilizerModalCancel: { flex: 1, backgroundColor: '#F3F7E6', borderRadius: 999, paddingVertical: 14, alignItems: 'center', borderWidth: 1, borderColor: green }, fertilizerModalCancelText: { color: green, fontSize: 15, fontWeight: '900' }, fertilizerModalConfirm: { flex: 1, backgroundColor: green, borderRadius: 999, paddingVertical: 14, alignItems: 'center', borderWidth: 1, borderColor: green }, fertilizerModalConfirmText: { color: '#fff', fontSize: 15, fontWeight: '900' }, // Fertilizer hand-drawn green box on the PLANT SETUP form (Bekky, 2026-09-02 // rework): matches the plantContextLasso green box style used elsewhere. fertGroupBox: { borderWidth: 1.6, borderColor: 'rgba(85,116,94,0.55)', borderRadius: 14, backgroundColor: 'rgba(253,247,235,0.35)', paddingHorizontal: 10, paddingVertical: 10, marginBottom: 10, }, fertGroupHint: { color: '#2D4A2D', fontSize: 12, lineHeight: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 }, // Cohort card additions (Bekky, 2026-08-30, chunk B-adjacent): pencil, // chevron, expanded member list, and the space Water-all button — same // house style as the rest of the space card (no new style language). spaceGroupMemberList: { paddingTop: 2, paddingBottom: 4, paddingLeft: 8 }, spaceGroupMember: { color: '#2E4B35', fontSize: 13, lineHeight: 18 }, // Space-detail redesign (Bekky, 2026-08-30): top Water-all, tile headers, // outlined chevrons, compact member tiles. spaceWaterAllTopBtn: { marginHorizontal: 16, marginBottom: 12, paddingVertical: 12, paddingHorizontal: 6, borderRadius: 16, borderWidth: 1.5, borderColor: '#2E7DB8', backgroundColor: '#EAF4FC', alignItems: 'center', justifyContent: 'center', }, spaceWaterAllTopText: { color: '#2E7DB8', fontSize: 16, fontWeight: '900' }, spaceFeedAllTopBtn: { marginHorizontal: 16, marginBottom: 12, paddingVertical: 12, paddingHorizontal: 6, borderRadius: 16, borderWidth: 1.5, borderColor: '#2E6B3F', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center', }, spaceFeedAllTopText: { color: '#2E6B3F', fontSize: 16, fontWeight: '900' }, spaceWaterAllFinePrint: { color: '#8A8266', fontSize: 11, marginTop: 3, textAlign: 'center', alignSelf: 'stretch', flexWrap: 'wrap' }, spaceTileHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, spaceTileHeaderTitle: { flex: 1, minWidth: 0 }, spaceTileChevron: { width: 30, height: 30, borderRadius: 8, borderWidth: 1.5, borderColor: '#2E6B3F', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, spaceTileChevronGlyph: { fontSize: 19, color: '#2E6B3F', fontWeight: '900', lineHeight: 22 }, spaceGroupPencil: { width: 30, height: 30, borderRadius: 8, borderWidth: 1.5, borderColor: '#2E6B3F', backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center', marginLeft: 6, marginRight: 6 }, spaceGroupPencilText: { fontSize: 15, color: '#2E6B3F', fontWeight: '800', lineHeight: 18 }, spaceGroupTileWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, paddingTop: 8, paddingBottom: 4, paddingLeft: 4 }, spaceGroupTile: { width: '23%', minWidth: 64, alignItems: 'center' }, spaceGroupTileFrame: { width: 56, height: 56, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', overflow: 'hidden' }, spaceGroupTilePhoto: { width: '100%', height: '100%' }, spaceGroupTilePlaceholder: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center' }, spaceGroupTilePlaceholderText: { fontSize: 22 }, spaceGroupTileName: { color: '#2E4B35', fontSize: 10, fontWeight: '700', marginTop: 3, lineHeight: 13, textAlign: 'center' }, // INTELLIGENT GROUPINGS TOGGLE (Bekky, 2026-08-30 polish): word button in a // rectangle — green On / red Off. groupingsToggleBtn: { minWidth: 64, paddingVertical: 7, paddingHorizontal: 16, borderRadius: 10, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center', }, groupingsToggleOn: { borderColor: '#2E6B3F', backgroundColor: '#E8F5D3' }, groupingsToggleOff: { borderColor: '#B04A3A', backgroundColor: '#FBE9EC' }, groupingsToggleText: { fontSize: 14, fontWeight: '900' }, groupingsToggleOnText: { color: '#2E6B3F' }, groupingsToggleOffText: { color: '#B04A3A' }, // TILE CHEVRONS (Bekky, 2026-08-30 polish #2): floating (no box), like the // plant-detail section chevrons. Down = collapsed (tap to open), up = expanded. // EXACT CollapsibleSection standard (Bekky: uniform across app) — 32x32 // round invisible touch target, open-chevron glyph 22, rotate -90 collapsed. spaceTileFloatingChevron: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, spaceTileFloatingChevronGlyph: { fontSize: 22, color: green, fontWeight: '900', lineHeight: 24 }, spaceTileFloatingChevronGlyphCollapsed: { transform: [{ rotate: '-90deg' }] }, spaceWaterAllBtn: { marginTop: 10, paddingVertical: 10, paddingHorizontal: 12, borderRadius: 999, borderWidth: 1, borderColor: '#2E7DB8', alignItems: 'center', }, spaceWaterAllBtnText: { color: '#2E7DB8', fontSize: 14, fontWeight: '800' }, wishlistNotes: { color: '#5D5A4E', fontSize: 12, lineHeight: 17, fontWeight: '700', marginTop: 2, marginBottom: 9 }, careSheetOverlay: { flex: 1, justifyContent: 'flex-end' }, gardenEntityPhoto: { width: 68, height: 68, borderRadius: 18, backgroundColor: '#E8F5D3' }, gardenDropdownOptionText: { color: dark, fontSize: 13, fontWeight: '800' }, deleteYesButtonText: { color: '#8E3D2F', fontSize: 15, fontWeight: '900' }, invDetailBottomSpacer: { height: 40 }, // Investigation list card styles invDetailTimelineDetail: { fontSize: 12, color: '#78694B' }, gardenInput: { minHeight: 42, borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, color: '#2F302A', fontSize: 13, fontWeight: '800', marginBottom: 8 }, gardenSpaceGroupTitle: { color: green, fontSize: 14, lineHeight: 18, fontWeight: '900', marginBottom: 8, paddingHorizontal: 3 }, gardenDropdownMenu: { borderRadius: 18, borderWidth: 1, borderColor: line, backgroundColor: 'rgba(255,253,247,0.98)', overflow: 'hidden', marginBottom: 9 }, invDetailTimelineTitle: { fontSize: 13, color: '#2E6B3F', fontWeight: '600' }, contextPhotoRemove: { minHeight: 32, borderRadius: 16, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, invDetailPhotoEmoji: { fontSize: 24 }, problemScanBodyText: { color: '#5E5845', fontSize: 14, lineHeight: 20, fontWeight: '600' }, gardenDropdownButtonText: { color: dark, fontSize: 11, fontWeight: '900', textAlign: 'center' }, careSheetSubtitle: { color: '#6A654F', fontSize: 12, lineHeight: 17, fontWeight: '700', marginBottom: 10 }, wishlistPrimaryActionText: { color: '#fff', fontSize: 12, fontWeight: '900' }, problemScanSection: { marginTop: 12 }, plantDetailHero: { width: 156, height: 156, borderRadius: 22, alignSelf: 'center', backgroundColor: '#E8F5D3' }, invDetailStatusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }, gardenSegmentTextActive: { color: '#fff' }, contextPhotoRemoveText: { color: '#8E3D2F', fontSize: 10, fontWeight: '900' }, invDetailBadgeText: { fontSize: 12, color: '#fff', fontWeight: '600' }, rowTitle: { fontSize: 13, fontWeight: '700', color: '#233225', flex: 1, minWidth: 0 }, spaceDetailArtworkBackplate: { width: '100%', height: 184, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(191,217,180,0.58)', backgroundColor: '#EDF7DD', alignItems: 'center', justifyContent: 'center', paddingVertical: 0, marginBottom: 10, overflow: 'visible' }, spaceEnvTag: { color: '#4E6A3E', backgroundColor: '#EAF3D6', borderRadius: 12, borderWidth: 1, borderColor: '#C9DFB4', paddingHorizontal: 8, paddingVertical: 4, fontSize: 10, lineHeight: 12, fontWeight: '900', overflow: 'hidden' }, assignPlantsPanel: { marginTop: 10, paddingTop: 9, borderTopWidth: 1, borderTopColor: 'rgba(228,220,196,0.82)' }, plantHistoryTitle: { color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900' }, plantHistoryNote: { color: '#5D5A4E', fontSize: 12, lineHeight: 16, fontWeight: '700', marginTop: 1 }, detailTargetHighlight: { borderRadius: 18, borderWidth: 1, borderColor: '#A9D27B', backgroundColor: '#F1F8E6', shadowColor: '#6BAF67', shadowOpacity: 0.16, shadowRadius: 8, elevation: 2 }, problemScanHeaderTitle: { color: '#2D4A2D', fontSize: 18, fontWeight: '900', flex: 1 }, problemScanCard: { backgroundColor: 'rgba(255,253,247,0.96)', borderRadius: 24, borderWidth: 1, borderColor: 'rgba(217,208,181,0.9)', padding: 13, marginBottom: 11, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 10, elevation: 3 }, invDetailSymptomChip: { backgroundColor: '#F3F7E6', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#D6E7BE' }, problemScanBackText: { color: '#5B7553', fontSize: 14, fontWeight: '800' }, gardenTilePlant: { width: '100%', height: '100%' }, problemScanHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 }, spaceTagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 7 }, careSheetCancelText: { color: '#8E2F1E', fontSize: 12, fontWeight: '900' }, plantDetailEditButton: { marginTop: 2, marginBottom: 14, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE' }, plantDetailEditButtonText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, invDetailPhoto: { width: 100, height: 100, borderRadius: 8 }, plantHistoryThumb: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#E8F5D3', flexShrink: 0 }, problemScanBackButton: { paddingVertical: 6, paddingRight: 12 }, plantDetailHeroPlaceholder: { alignItems: 'center', justifyContent: 'center', backgroundColor: '#F1F7E7', borderWidth: 1, borderColor: '#D6E7BE' }, careSheetOptionText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, memoryBackLink: { color: green, fontSize: 13, fontWeight: '800', marginBottom: 8 }, plantReferenceLinkText: { color: green, fontSize: 13, fontWeight: '900' }, plantHistoryRowHighlight: { borderColor: 'rgba(122,170,100,0.5)', backgroundColor: '#F7FBF2' }, invCardBadge: { borderRadius: 12, paddingHorizontal: 8, paddingVertical: 3, marginLeft: 8 }, invDetailSymptomText: { fontSize: 13, color: '#5B7553' }, invDetailPhotoSymptomRow: { flexDirection: 'row', gap: 12, marginTop: 4 }, invDetailPhotoGridSingle: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, alignSelf: 'flex-start', maxWidth: 140 }, invDetailSymptomList: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: 2, columnGap: 14, alignContent: 'flex-start' }, invDetailSymptomListItem: { flexDirection: 'row', alignItems: 'flex-start', gap: 5, width: '46%' }, invDetailSymptomBullet: { color: '#5B7553', fontSize: 12, lineHeight: 17 }, invDetailSymptomListItemText: { flex: 1, color: '#5B7553', fontSize: 12, lineHeight: 17, fontWeight: '600' }, gardenEntityRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 8 }, addPlantTile: { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }, careSheet: { margin: 14, marginBottom: 28, borderRadius: 22, borderWidth: 1, borderColor: 'rgba(228,220,196,0.95)', backgroundColor: '#FFFDF7', padding: 14 }, gardenTileDragSurface: { width: '100%', height: '100%', flexDirection: 'column', alignItems: 'stretch', position: 'relative' }, problemScanConfidenceValue: { color: '#5E5845', fontWeight: '600' }, // Investigation detail view styles plantPhotoButton: { minHeight: 48, borderRadius: 24, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', marginTop: 12 }, contextPhotoThumb: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#E8F5D3', flexShrink: 0 }, contextPhotoThumbWrap: { width: 48, height: 48, flexShrink: 0, borderRadius: 12, overflow: 'hidden' }, addPlantPlusVertical: { position: 'absolute', width: 2.5, height: 20, borderRadius: 1.25, backgroundColor: '#fff' }, careSheetCancel: { minHeight: 38, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, marginTop: 2 }, photoTileRemove: { minHeight: 28, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 11 }, photoTileRemoveText: { color: '#8E2F1E', fontSize: 12, fontWeight: '900' }, placementTileRemove: { position: 'absolute', top: 4, right: 4, minHeight: 24, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8, zIndex: 5 }, buttonDisabled: { opacity: 0.55 }, body: { fontSize: 16, fontWeight: '500', color: '#2F302A', lineHeight: 22 }, careSheetTitle: { color: dark, fontSize: 16, lineHeight: 21, fontWeight: '900', marginBottom: 3 }, gardenSegmented: { flexDirection: 'row', gap: 8, marginBottom: 9 }, safe: { flex: 1, backgroundColor: 'transparent' }, plantIntelligenceNextButtonText: { color: dark, fontSize: 12, fontWeight: '900' }, setupReadonlyLabel: { color: green, fontSize: 11, lineHeight: 14, fontWeight: '900', textTransform: 'uppercase', marginBottom: 3 }, deleteConfirmOverlay: { flex: 1, backgroundColor: 'rgba(32,45,26,0.32)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28 }, invDetailPhotoOverlay: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.6)', borderBottomLeftRadius: 8, borderBottomRightRadius: 8, paddingVertical: 2, alignItems: 'center' }, formHelper: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: -2, marginBottom: 7 }, small: { fontSize: 12, color: '#5D5A4E', lineHeight: 17 }, deleteConfirmTitle: { color: dark, fontSize: 18, lineHeight: 23, fontWeight: '900', textAlign: 'center' }, plantHistoryThumbLarge: { width: 56, height: 56, borderRadius: 14 }, formLabel: { color: dark, fontSize: 13, fontWeight: '900', marginTop: 4, marginBottom: 6 }, plantHistoryIcon: { width: 48, height: 48, borderRadius: 12, backgroundColor: '#F1F7E7', borderWidth: 1, borderColor: '#D6E7BE', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, // Faint divider between logical sections in the space form (Bekky, 2026-08-23) // — groups related chips (window + open-frequency + light) so it's clear they // belong together. formSectionDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#E2DDD0', marginVertical: 16 }, gardenTilePlantFrame: { width: '100%', height: 96, borderRadius: 10, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0, backgroundColor: '#E8F5D3' }, cuttingBadge: { position: 'absolute', top: 4, right: 4, width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.92)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', alignItems: 'center', justifyContent: 'center', zIndex: 2 }, cuttingBadgeText: { fontSize: 16, lineHeight: 20 }, seedBadge: { position: 'absolute', top: 4, right: 4, width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.92)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', alignItems: 'center', justifyContent: 'center', zIndex: 2 }, seedBadgeText: { fontSize: 16, lineHeight: 20 }, invDetailTimelineDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#4CAF50', marginTop: 5, marginRight: 10 }, statVal: { fontWeight: '700', color: '#4B4A3B', fontSize: 13, flexShrink: 1, textAlign: 'right' }, buttonText: { color: '#fff', fontSize: 15, fontWeight: '900', textAlign: 'center' }, careSheetOption: { minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginBottom: 7 }, // Cohesive buttons for the "Move to Space" sheet (Bekky, 2026-08-19). Cancel // is the smaller secondary pill; Save Space is the big primary. Both on the // SAME row (same height, no margin offset) — alignItems:center handles any // rounding. careSheetCancelButton: { flex: 0, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 22 }, careSheetCancelButtonText: { color: green, fontSize: 15, fontWeight: '900', textAlign: 'center' }, careSheetSaveButton: { flex: 1, minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, spaceStatus_good: { color: '#2F6D3A', backgroundColor: '#E3F4D6', borderColor: '#BCDFA9' }, button: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginTop: 8 }, gardenTileTitle: { maxWidth: '100%', color: dark, fontSize: 13, lineHeight: 17, fontWeight: '900', marginTop: 6, textAlign: 'center', flexShrink: 1 }, contextPhotoEmpty: { borderRadius: 16, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 10, marginBottom: 8 }, setupTopCancelButton: { alignSelf: 'flex-start', flex: 0, minWidth: 112, marginBottom: 12 }, plantRowImagePlaceholderText: { color: '#6B7E6E', fontSize: 9, lineHeight: 12, fontWeight: '900' }, plantIntelligenceNextBox: { borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#E8F5D3', padding: 10, marginTop: 10 }, invCardPhotoPlaceholder: { backgroundColor: '#E8F5D3', alignItems: 'center', justifyContent: 'center' }, plantDetailHeroFrame: { width: '100%', height: 184, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(191,217,180,0.58)', backgroundColor: '#EDF7DD', alignItems: 'center', justifyContent: 'center', paddingVertical: 0, marginBottom: 10, overflow: 'visible' }, deleteConfirmActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 28, marginTop: 18 }, formSecondaryButtonDanger: { flex: 1, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#F4C6B8', backgroundColor: '#FFE3DB', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, invDetailTitleFlex: { flex: 1, marginRight: 8 }, gardenEntityCopy: { flex: 1, minWidth: 0 }, setupReadonlyHint: { color: '#7A704D', fontSize: 11, lineHeight: 15, fontWeight: '700', marginTop: 3 }, problemScanListBullet: { color: '#5B7553', fontSize: 13, fontWeight: '900', marginRight: 6 }, plantHistoryList: { gap: 8, marginTop: 4 }, gardenFormPhoto: { width: '100%', height: 126, borderRadius: 16, backgroundColor: '#E8F5D3', marginBottom: 8 }, addPlantTileIcon: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#68AF67', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, plantReferenceLink: { alignSelf: 'flex-start', minHeight: 38, borderRadius: 19, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, marginTop: 12 }, fungusDetailCard: { marginTop: 10, padding: 14, borderRadius: 12, backgroundColor: '#FDF0E7', borderWidth: 1, borderColor: '#E7C9A8' }, fungusDetailTitle: { color: '#9A5B2A', fontSize: 13, fontWeight: '900', marginTop: 8, lineHeight: 18 }, fungusDetailText: { color: '#7A5B3A', fontSize: 12, lineHeight: 17, fontWeight: '600', marginTop: 4 }, fungusDetailLabel: { color: '#7A5B3A', fontSize: 12, fontWeight: '800', marginTop: 4, lineHeight: 17 }, fungusDetailValue: { color: '#4A3A28', fontSize: 12, fontWeight: '700' }, invDetailDateText: { fontSize: 12, opacity: 0.5, marginTop: 4 }, memoryCategoryLabel: { color: dark, fontSize: 15, fontWeight: '900' }, invDetailTimelineDate: { fontSize: 11, color: '#9E9E9E' }, invTreatmentStep: { backgroundColor: '#F3F7E6', borderRadius: 14, borderWidth: 1, borderColor: '#D6E7BE', padding: 12, marginBottom: 8 }, invTreatmentStepHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }, invTreatmentStepTitle: { color: dark, fontSize: 14, fontWeight: '800', flex: 1 }, invTreatmentStepDue: { color: '#5B7553', fontSize: 12, fontWeight: '700', marginTop: 2 }, invTreatmentStepOverdue: { color: '#B85C4A', fontWeight: '800' }, invTreatmentOverduePrompt: { color: '#8E2F1E', fontSize: 12, fontWeight: '600', marginTop: 6, lineHeight: 16 }, invTreatmentWarning: { color: '#8E2F1E', fontSize: 12, fontWeight: '600', marginTop: 8, lineHeight: 16 }, invMaterialsBlock: { marginTop: 8, backgroundColor: '#F3F7E6', borderRadius: 10, padding: 10 }, invMaterialsTitle: { color: '#5B7553', fontSize: 12, fontWeight: '800', marginBottom: 4 }, invMaterialsText: { flex: 1, color: '#5B7553', fontSize: 12, lineHeight: 16, fontWeight: '600' }, caseLogButton: { minHeight: 46, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginTop: 14, paddingHorizontal: 20 }, caseLogButtonText: { color: '#FFF', fontSize: 15, fontWeight: '800' }, invExecHistory: { backgroundColor: '#FFF9EE', borderRadius: 12, borderWidth: 1, borderColor: '#D6E7BE', padding: 12, marginTop: 12 }, invExecHistoryTitle: { color: '#5B7553', fontSize: 13, fontWeight: '800', marginBottom: 8 }, invExecHistoryRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 8, marginBottom: 6 }, invExecHistoryLabel: { color: green, fontSize: 12, fontWeight: '700', width: 72 }, invExecHistoryText: { flex: 1, color: '#2D4A2D', fontSize: 12, lineHeight: 16, fontWeight: '600' }, invProgressUpdate: { backgroundColor: '#FFF9EE', borderRadius: 14, borderWidth: 1, borderColor: '#D6E7BE', padding: 12, marginBottom: 8 }, invProgressUpdateHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 6 }, invProgressUpdateTitle: { color: dark, fontSize: 14, fontWeight: '800', flex: 1 }, invProgressUpdateDate: { fontSize: 11, color: '#9E9E9E' }, invProgressUpdateEarly: { color: '#B85C4A', fontSize: 12, fontWeight: '600', marginBottom: 6 }, invProgressUpdatePhoto: { width: '100%', height: 120, borderRadius: 12, backgroundColor: '#E8F5D3', marginBottom: 8 }, problemScanResultSummary: { color: '#2D4A2D', fontSize: 15, fontWeight: '800', lineHeight: 22, marginBottom: 12 }, gardenTilePlantPlaceholder: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', backgroundColor: '#F1F7E7' }, assignPlantText: { flex: 1, minWidth: 0 }, gardenDropdownRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, gardenDropdownButton: { flex: 1, minHeight: 39, borderRadius: 20, borderWidth: 1, borderColor: line, backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 8 }, plantHistoryRow: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', padding: 8 }, plantPhotoButtonText: { color: dark, fontSize: 15, fontWeight: '900' }, invDetailBadge: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4 }, bold: { fontWeight: '900', color: dark }, problemScanConfidence: { color: '#5B7553', fontSize: 12, fontWeight: '700', marginTop: 4 }, invDetailPhotoWrap: { position: 'relative' }, spaceStatus_needs: { color: '#8E5631', backgroundColor: '#FFF1CF', borderColor: '#E7C778' }, plantRowImagePlaceholder: { borderRadius: 8, backgroundColor: '#F1F7E7', alignItems: 'center', justifyContent: 'center' }, spaceStatusPill: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 9, paddingVertical: 5, fontSize: 10, lineHeight: 12, fontWeight: '900', overflow: 'hidden' }, invCardMetaRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, });