/** * Identify screen for PixieSprout. * Camera-based plant identification and problem scanning. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Image as ExpoImage } from 'expo-image'; import { ActivityIndicator, Alert, Animated, BackHandler, Easing, Image, ImageSourcePropType, Modal, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system'; import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { green, dark, haptic, pressWithHaptic } from '../constants/theme'; import { PhotoPickerModal } from '../components/PhotoPickerModal'; import { img } from '../constants/images'; import { navAspect, minStableBottomInset, maxStableBottomInset, useStableBottomInset } from '../constants/layout'; import { UiIcon, Card, Section, GardenMultiChips } from '../components/BaseUI'; import { FormTextField } from '../components/FormUI'; import { isIndoorSpaceType, isOutdoorSpaceType } from '../utils/formatters'; import type { IdentifyMode, IdentifyImage, IdentifyRequest } from '../types/appTypes'; import type { SavedPlantProfile } from '../types/plantScan'; import type { WishlistItem, WishlistVisibility, GpsPrecision, PlantEvent, RoomProfile } from '../types/garden'; import { persistPhoto } from '../services/photoStorage'; import { analyzeProblem, type ProblemScanContextType, type ProblemScanInput, type ProblemScanResult } from '../services/problemScanService'; import { buildInvestigationContextPacket } from '../services/plantIntelligence/plantIntelligenceService'; import { buildPlantMemoryContext, renderMemoryContextBlock, renderOpenCaseContext } from '../services/plantIntelligence/memoryContext'; import { createInvestigation, linkMemoryToInvestigation, getOpenInvestigationForPlant, appendPointToInvestigation, setInvestigationTreatmentPlan } from '../services/investigation'; import type { Investigation, InvestigationPhoto, InvestigationPhotoCategory } from '../services/investigation'; import { createGardenId } from '../services/gardenStorage'; import { savedGardenKey } from '../types/appTypes'; import { PlantModeScreen } from './PlantModeScreen'; const HELPER_INTRO_DURATION_MS = 3200; const problemLabels = ['Leaf spot', 'Fruit issue', 'Pest close-up', 'Stem', 'Soil', 'Mold', 'Other']; // Map a user-tagged photo label to an InvestigationPhotoCategory so each photo // in a case is labeled by what it actually shows, not by capture order. function mapProblemLabelToCategory(label?: string): InvestigationPhotoCategory { switch (label) { case 'Leaf spot': return 'area_of_concern'; case 'Fruit issue': return 'area_of_concern'; case 'Pest close-up': return 'close_up'; case 'Stem': return 'close_up'; case 'Soil': return 'soil'; case 'Mold': return 'area_of_concern'; case 'Other': return 'close_up'; default: return 'area_of_concern'; } } // A single golden star that gently twinkles (fades in/out) with a stagger delay. function SparkleTwinkle({ size, delay }: { size: number; delay: number }) { const opacity = useRef(new Animated.Value(0.2)).current; useEffect(() => { const loop = Animated.loop( Animated.sequence([ Animated.timing(opacity, { toValue: 1, duration: 700, delay, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), Animated.timing(opacity, { toValue: 0.2, duration: 700, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), ]) ); loop.start(); return () => loop.stop(); }, [opacity, delay]); return ( ); } const symptomLabelMapLocal: Record = { yellow_leaves: 'yellow leaves', brown_tips: 'brown tips', drooping: 'drooping', spots_patches: 'spots or patches', bugs_pests: 'bugs or pests', webbing: 'webbing', mold_fungus: 'mold or fungus', wilting: 'wilting', slow_growth: 'slow growth', leaf_damage: 'leaf damage', other: 'other issue', holes: 'holes in leaves', malformed_leaves: 'malformed leaves', curled_leaves: 'curled leaves', discoloration: 'discoloration', browning: 'browning or scorched', white_powder: 'white powder', sticky_residue: 'sticky residue', mushy_stems: 'mushy stems or rot', leggy: 'leggy or stretched', }; const problemScanSymptomOptions = [ { value: 'yellow_leaves', label: 'Yellow leaves' }, { value: 'brown_tips', label: 'Brown tips' }, { value: 'drooping', label: 'Drooping' }, { value: 'wilting', label: 'Wilting' }, { value: 'spots_patches', label: 'Spots or patches' }, { value: 'holes', label: 'Holes in leaves' }, { value: 'malformed_leaves', label: 'Malformed leaves' }, { value: 'curled_leaves', label: 'Curled leaves' }, { value: 'leaf_damage', label: 'Leaf damage' }, { value: 'discoloration', label: 'Discoloration' }, { value: 'browning', label: 'Browning / scorched' }, { value: 'white_powder', label: 'White powder' }, { value: 'sticky_residue', label: 'Sticky residue' }, { value: 'bugs_pests', label: 'Bugs or pests' }, { value: 'webbing', label: 'Webbing' }, { value: 'mold_fungus', label: 'Mold or fungus' }, { value: 'mushy_stems', label: 'Mushy stems / rot' }, { value: 'leggy', label: 'Leggy / stretched' }, { value: 'slow_growth', label: 'Slow growth' }, { value: 'other', label: 'Other' }, ]; const identifyModes: { key: IdentifyMode; title: string; subtitle: string; icon: ImageSourcePropType; instruction: string; }[] = [ { key: 'plant', title: 'Plant', subtitle: 'Identify a plant, tree, crop, or cutting.', icon: img.identifyOptionPlant, instruction: 'Center the whole plant or a clear leaf in the frame.', }, { key: 'problem', title: 'Problem', subtitle: 'Check spots, pests, damage, or plant stress.', icon: img.identifyOptionProblem, instruction: 'Center the affected area in the frame.', }, { key: 'seedTag', title: 'Seed / Tag', subtitle: 'Scan a seed packet, a loose seed, a fruit with seeds, or a nursery tag.', icon: img.identifyOptionSeedTag, instruction: 'Center the packet or tag so the text is readable.', }, { key: 'fungus', title: 'Fungus / Mushroom', subtitle: 'ID mushrooms, bracket fungi, lichen, moss, or other fungi.', icon: img.identifyOptionPlant, instruction: 'Center the cap and stem (or whole fruiting body) in the frame.', }, ]; function makeIdentifyRequest(mode: IdentifyMode, images: IdentifyImage[], analyzeTogether: boolean): IdentifyRequest { return { mode, images, analyzeTogether, // Per the Identify-flow spec, captured/gallery images must have precise // location EXIF stripped before upload/share; this flag carries that contract. metadata: { exifStripped: true, tab: 'Identify' }, }; } function IdentifyModePicker({ onSelect }: { onSelect: (mode: IdentifyMode) => void }) { return ( What would you like PixieSprout to check? Choose a photo type before opening the camera. {identifyModes.map(option => { const enabled = option.key === 'plant' || option.key === 'problem' || option.key === 'fungus' || option.key === 'seedTag'; return ( onSelect(option.key)) : undefined} disabled={!enabled} > {option.title} {!enabled ? Coming soon : null} {option.subtitle} ' : '-'} size={22} color={enabled ? green : '#B9B29E'} /> ); })} ); } export function IdentifyScreen({ onPlantSaved, onPlantUpdated, onViewPlant, onSaveWishlist, onSaveFieldDiaryEntry, wishlistVisibility, onScanModeChange, savedPlants, pendingProblemScanContext, setPendingProblemScanContext, investigations, setInvestigations, savedGpsPrecision, rememberGpsPrecision, savedApproximateLocationContext, carePreferences, rooms, }: { onPlantSaved: (profile: SavedPlantProfile) => void; onPlantUpdated: (plantId: string, patch: Partial) => void; onViewPlant: (plantId: string) => void; onSaveWishlist: (item: WishlistItem) => void; onSaveFieldDiaryEntry: (entry: import('../types/garden').FieldDiaryEntry, placeLabel: string) => void; wishlistVisibility: WishlistVisibility; onScanModeChange: (active: boolean) => void; savedPlants?: SavedPlantProfile[]; pendingProblemScanContext: { imageUri: string; symptoms: string[]; userNote?: string; aiResult?: ProblemScanResult; } | null; setPendingProblemScanContext: (ctx: { imageUri: string; symptoms: string[]; userNote?: string; aiResult?: ProblemScanResult; investigationId?: string; } | null) => void; investigations: Investigation[]; setInvestigations: React.Dispatch>; savedGpsPrecision: GpsPrecision; rememberGpsPrecision: boolean; savedApproximateLocationContext?: string | null; /** User's care preferences — fed to the AI to refine identification/advice */ carePreferences?: { organicFirst?: boolean; petSafeCaution?: boolean; ediblePlantCaution?: boolean; shoppingStyle?: 'diy_first' | 'store_bought' | 'either'; }; /** Garden spaces (rooms) — used to group the problem-scan plant picker by location */ rooms?: RoomProfile[]; }) { const insets = useSafeAreaInsets(); const { width: screenWidth } = useWindowDimensions(); const stableBottomInset = useStableBottomInset(insets.bottom); // Match the bottom nav's geometry so the helper fairy rests on its top edge. const navHeight = screenWidth * 0.95 * navAspect; const mascotBottom = stableBottomInset + navHeight - 20; const cameraRef = useRef(null); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [mode, setMode] = useState(null); const [torchOn, setTorchOn] = useState(false); const [problemImages, setProblemImages] = useState([]); const [lastRequest, setLastRequest] = useState(null); const [isCapturing, setIsCapturing] = useState(false); const [helperPhase, setHelperPhase] = useState<'intro' | 'idle'>('intro'); const helperIntroStarted = useRef(false); const helperIntroTimer = useRef | null>(null); // Audit-fix refs (added by the returned module's stale/cancel protection): // guard against duplicate investigation saves and reject stale analysis results. const saveInvestigationInFlightRef = useRef(false); const analysisEpochRef = useRef(0); // Spinning pixie spinner animation (JS-driven rotation, works over OTA) const spinnerRotation = useRef(new Animated.Value(0)).current; const [spinnerSpin] = useState(() => { Animated.loop( Animated.timing(spinnerRotation, { toValue: 1, duration: 2600, easing: Easing.linear, useNativeDriver: true }) ).start(); return null; }); const spinnerRotate = spinnerRotation.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'], }); // Scattered sparkles over the green void while analyzing — all kept BELOW the // top info card (so none overlap the text) and away from the lower-left fairy. const sparklePositions = useMemo(() => [ { top: '42%', left: '10%', size: 16, delay: 0 }, { top: '45%', left: '85%', size: 12, delay: 400 }, { top: '52%', left: '35%', size: 14, delay: 800 }, { top: '48%', left: '88%', size: 10, delay: 200 }, { top: '58%', left: '78%', size: 18, delay: 600 }, { top: '65%', left: '40%', size: 12, delay: 300 }, { top: '72%', left: '85%', size: 10, delay: 900 }, ] as { top: `${number}%`; left: `${number}%`; size: number; delay: number }[], []); // Problem Scan state const [problemScanStep, setProblemScanStep] = useState<'context' | 'symptoms' | 'analyzing' | 'result' | null>(null); const [problemScanContextType, setProblemScanContextType] = useState(null); const [problemScanLinkedPlantId, setProblemScanLinkedPlantId] = useState(null); const [problemScanImage, setProblemScanImage] = useState(null); const [problemScanImageBase64, setProblemScanImageBase64] = useState(null); const [problemScanSymptoms, setProblemScanSymptoms] = useState([]); const [problemScanPhotoSymptoms, setProblemScanPhotoSymptoms] = useState>({}); const [activePhotoUri, setActivePhotoUri] = useState(null); const [problemScanNote, setProblemScanNote] = useState(''); const [showPhotoPicker, setShowPhotoPicker] = useState(false); const [problemScanResult, setProblemScanResult] = useState(null); const [problemScanError, setProblemScanError] = useState(null); const [problemScanAnalyzing, setProblemScanAnalyzing] = useState(false); const [problemScanShowFollowUp, setProblemScanShowFollowUp] = useState(false); const [problemScanPassedImage, setProblemScanPassedImage] = useState(null); // Investigation state const [problemScanPhotos, setProblemScanPhotos] = useState([]); const [currentInvestigationId, setCurrentInvestigationId] = useState(null); const selectedMode = identifyModes.find(option => option.key === mode); const cameraGranted = cameraPermission?.granted === true; useEffect(() => { onScanModeChange(Boolean(mode)); return () => onScanModeChange(false); }, [mode, onScanModeChange]); // When Problem mode is selected, show the context question first (before camera) useEffect(() => { if (mode === 'problem' && !problemScanStep && !problemScanContextType) { setProblemScanStep('context'); } }, [mode]); // System back button handles the problem-scan steps (so we can drop the custom // header). context → exit scan; symptoms → exit scan (NOT back to the camera // tray — that reveals the old camera-tray UI); result → exit scan. useEffect(() => { if (mode !== 'problem') return undefined; const sub = BackHandler.addEventListener('hardwareBackPress', () => { if (problemScanStep === 'context' || problemScanStep === 'symptoms' || problemScanStep === 'analyzing' || problemScanStep === 'result') { resetProblemScan(); changeMode(); return true; } return false; }); return () => sub.remove(); }, [mode, problemScanStep]); useEffect(() => { if (!mode || helperIntroStarted.current) return undefined; helperIntroStarted.current = true; setHelperPhase('intro'); const timer = setTimeout(() => setHelperPhase('idle'), HELPER_INTRO_DURATION_MS); return () => clearTimeout(timer); }, [mode]); useEffect(() => { // Only request the camera permission once the user has actually chosen a // mode and opened the capture flow — not the moment the Scan tab mounts. if (mode && cameraPermission && !cameraPermission.granted && cameraPermission.canAskAgain) { requestCameraPermission(); } }, [mode, cameraPermission, requestCameraPermission]); const addImage = (image: IdentifyImage) => { if (saveInvestigationInFlightRef.current) return; saveInvestigationInFlightRef.current = true; try { if (!mode) return; setLastRequest(null); if (mode === 'problem') { setProblemScanImage(image.uri); // Populate BOTH photo arrays so the capture tray (problemImages) and the // analysis/investigation (problemScanPhotos) stay in sync. The tray was // orphaned during extraction — it read problemImages which was never filled. setProblemImages(prev => { if (prev.some(p => p.uri === image.uri)) return prev; return [...prev, image]; }); setProblemScanPhotos(prev => { // Avoid duplicates if (prev.includes(image.uri)) return prev; return [...prev, image.uri]; }); // Make the newly added photo the active one so symptoms tag it immediately setActivePhotoUri(image.uri); // Go to the symptoms screen — this is where the multi-photo UI lives // (thumbnails + "+" to add more + "Take Photo/Choose Photo/Cancel"). // This matches the June 11 design: capture → symptoms → Ask Pixie. if (problemScanContextType) { setProblemScanStep('symptoms'); } else { setProblemScanStep('context'); } return; } setLastRequest(makeIdentifyRequest(mode, [image], false)); } finally { saveInvestigationInFlightRef.current = false; } }; const capturePhoto = async () => { if (!mode || isCapturing) return; if (!cameraGranted) { const permission = await requestCameraPermission(); if (!permission.granted) return; } if (!cameraRef.current) return; setIsCapturing(true); try { const photo = await cameraRef.current.takePictureAsync({ quality: 0.84, skipProcessing: false }); if (photo?.uri) { addImage({ uri: photo.uri, source: 'camera', capturedAt: new Date().toISOString() }); haptic.success(); } } catch { haptic.warning(); Alert.alert('Camera unavailable', 'The photo could not be captured. Please try again.'); } finally { setIsCapturing(false); } }; const importFromGallery = async () => { if (!mode) return; const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) return; try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsMultipleSelection: mode === 'problem', selectionLimit: mode === 'problem' ? 8 : 1, quality: 0.84, }); if (result.canceled) return; for (const asset of result.assets) { if (asset.uri) { try { const record = await persistPhoto(asset.uri, { photoType: mode === 'problem' ? 'investigation' : 'plant_id' }); addImage({ uri: record.activeUri, source: 'gallery', capturedAt: new Date().toISOString() }); } catch { addImage({ uri: asset.uri, source: 'gallery', capturedAt: new Date().toISOString() }); } } } haptic.success(); } catch { haptic.warning(); Alert.alert('Gallery unavailable', 'The image picker could not be opened. Please try again.'); } }; const analyzeProblemBatch = () => { if (problemImages.length === 0) return; // Route into the real scan flow — the symptoms step already shows the // captured photos and runs the actual diagnosis via "Ask Pixie". The old // behavior only set a handoff toast that went nowhere. setProblemScanStep('symptoms'); }; const changeMode = () => { // If we're in Problem Scan capture mode (context answered, waiting for photo), go back to context if (mode === 'problem' && problemScanStep === null && problemScanContextType !== null) { setProblemScanStep('context'); setProblemScanContextType(null); setProblemScanLinkedPlantId(null); return; } setTorchOn(false); setMode(null); // Allow the helper-fairy intro animation to play again for the next mode. helperIntroStarted.current = false; setHelperPhase('intro'); }; const setProblemLabel = (index: number, label: string) => { setProblemImages(current => current.map((image, i) => (i === index ? { ...image, label } : image))); }; // ─── Problem Scan handlers ────────────────────────────────────────────────── const resetProblemScan = () => { analysisEpochRef.current += 1; // audit-fix: cancel any pending analysis setProblemScanStep(null); setProblemScanContextType(null); setProblemScanLinkedPlantId(null); setProblemScanImage(null); setProblemScanImageBase64(null); setProblemScanSymptoms([]); setProblemScanNote(''); setProblemScanResult(null); setProblemScanError(null); setProblemScanAnalyzing(false); setProblemScanShowFollowUp(false); setProblemScanPassedImage(null); setProblemScanPhotos([]); setProblemScanPhotoSymptoms({}); setActivePhotoUri(null); setCurrentInvestigationId(null); // Clear the capture tray + handoff toast too — these were NOT cleared before, // so a "reset" left stale photos loaded for the next scan (the reset bug). setProblemImages([]); setLastRequest(null); // Note: do NOT clear pendingProblemScanContext here — it persists across Plant ID flow }; const handleProblemScanContextChoice = (choice: 'existingPlant' | 'unknownPlant') => { setProblemScanContextType(choice); if (choice === 'existingPlant') { // Will show plant picker inline — user selects plant, then camera shows } else { setProblemScanLinkedPlantId(null); setProblemScanStep(null); // Show camera UI for photo capture } }; const handleProblemScanPlantSelect = (plantId: string) => { setProblemScanLinkedPlantId(plantId); setProblemScanStep(null); // Show camera UI for photo capture }; const toggleProblemScanSymptom = (value: string) => { const targetUri = activePhotoUri || problemScanPhotos[0] || problemScanImage; if (!targetUri) return; setProblemScanPhotoSymptoms(prev => { const current = prev[targetUri] || []; const next = current.includes(value) ? current.filter(s => s !== value) : [...current, value]; return { ...prev, [targetUri]: next }; }); }; // Aggregate of ALL photos' symptoms — the flattened set used for the scan title // and backward-compatible flows. The AI gets the per-photo breakdown. const allProblemScanSymptoms = useMemo(() => { const seen = new Set(); Object.values(problemScanPhotoSymptoms).forEach(arr => arr.forEach(s => seen.add(s))); return Array.from(seen); }, [problemScanPhotoSymptoms]); // Build the per-photo symptom breakdown aligned to image order (primary first, // then each additional photo), each tagged with its own symptoms + category. const buildPhotoSymptoms = (): Array<{ symptoms: string[]; category?: string; label?: string }> => { const photos = problemScanImage ? [problemScanImage, ...problemScanPhotos.filter(uri => uri !== problemScanImage)] : [...problemScanPhotos]; const labeled = problemImages.length > 0 ? problemImages : []; return photos.map(uri => { const img = labeled.find(p => p.uri === uri); return { symptoms: problemScanPhotoSymptoms[uri] || [], category: img ? mapProblemLabelToCategory(img.label) : 'area_of_concern', label: img?.label, }; }); }; const addAdditionalProblemPhoto = async () => { if (problemScanPhotos.length >= 5) return; setShowPhotoPicker(true); }; const captureAdditionalPhoto = async () => { try { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') return; const result = await ImagePicker.launchCameraAsync({ quality: 0.84, allowsEditing: false, }); if (!result.canceled && result.assets[0]?.uri) { const uri = result.assets[0].uri; setProblemScanPhotos(prev => { if (prev.includes(uri)) return prev; if (prev.length >= 5) return prev; return [...prev, uri]; }); setActivePhotoUri(uri); } } catch { // Silently ignore camera errors } }; const pickAdditionalPhoto = async () => { try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.84, allowsMultipleSelection: false, }); if (!result.canceled && result.assets[0]?.uri) { const uri = result.assets[0].uri; setProblemScanPhotos(prev => { if (prev.includes(uri)) return prev; if (prev.length >= 5) return prev; return [...prev, uri]; }); setActivePhotoUri(uri); } } catch { // Silently ignore picker errors } }; const startProblemScanAnalysis = async () => { const analysisEpoch = ++analysisEpochRef.current; // audit-fix: reject stale results if (!problemScanImage) return; setProblemScanStep('analyzing'); setProblemScanAnalyzing(true); setProblemScanError(null); setProblemScanResult(null); // Replay the fairy intro ("I can help") then settle into the idle loop. setHelperPhase('intro'); if (helperIntroTimer.current) clearTimeout(helperIntroTimer.current); helperIntroTimer.current = setTimeout(() => setHelperPhase('idle'), HELPER_INTRO_DURATION_MS); try { // Optimize primary image — resize to max 768px longest side, JPEG 0.5 const optimizedPrimary = await manipulateAsync( problemScanImage, [{ resize: { width: 768 } }], { compress: 0.5, format: SaveFormat.JPEG, base64: true }, ); if (analysisEpoch !== analysisEpochRef.current) return; const base64 = optimizedPrimary.base64 || ''; setProblemScanImageBase64(base64); // Optimize additional investigation photos const additionalImagesBase64: string[] = []; const additionalPhotoUris = problemScanPhotos.filter(uri => uri !== problemScanImage); for (const uri of additionalPhotoUris) { try { const optimized = await manipulateAsync( uri, [{ resize: { width: 768 } }], { compress: 0.5, format: SaveFormat.JPEG, base64: true }, ); if (analysisEpoch !== analysisEpochRef.current) return; if (optimized.base64) { additionalImagesBase64.push(optimized.base64); } } catch { if (analysisEpoch !== analysisEpochRef.current) return; // Skip failed reads — non-critical } } // Build plant context — prefer intelligence context packet for linked plants let plantContext: ProblemScanInput['plantContext'] | undefined; if (problemScanLinkedPlantId && savedPlants) { const plant = savedPlants.find(p => p.id === problemScanLinkedPlantId); if (plant) { // Try intelligence context packet first let intelligenceUsed = false; try { const packet = await buildInvestigationContextPacket(problemScanLinkedPlantId); if (analysisEpoch !== analysisEpochRef.current) return; if (packet && (packet.identitySummary || packet.environmentSummary || packet.placementSummary || packet.historySummary)) { const contextParts: string[] = []; if (packet.identitySummary) contextParts.push(`Identity: ${packet.identitySummary}`); if (packet.environmentSummary) contextParts.push(`Environment: ${packet.environmentSummary}`); if (packet.placementSummary) contextParts.push(`Placement: ${packet.placementSummary}`); if (packet.setupSummary) contextParts.push(`Setup: ${packet.setupSummary}`); if (packet.historySummary) contextParts.push(`History: ${packet.historySummary}`); if (packet.growthPatternSummary) contextParts.push(`Growth: ${packet.growthPatternSummary}`); if (packet.recentTrendSummary) contextParts.push(`Recent trends: ${packet.recentTrendSummary}`); if (packet.activeIssuesSummary) contextParts.push(`Active issues: ${packet.activeIssuesSummary}`); if (packet.successfulInterventions.length > 0) { contextParts.push(`What worked before: ${packet.successfulInterventions.map(i => i.description).join('; ')}`); } if (packet.unsuccessfulInterventions.length > 0) { contextParts.push(`What didn't work: ${packet.unsuccessfulInterventions.map(i => i.description).join('; ')}`); } if (packet.candidateLearnings.length > 0) { contextParts.push(`Learnings: ${packet.candidateLearnings.map(l => l.learning).join('; ')}`); } plantContext = { name: plant.name || plant.commonName, scientificName: plant.scientificName || undefined, environment: packet.environmentSummary || plant.locationContext || undefined, setup: packet.setupSummary || plant.wateringSchedule || undefined, recentMemories: contextParts.length > 0 ? contextParts : undefined, }; intelligenceUsed = true; } } catch { if (analysisEpoch !== analysisEpochRef.current) return; // Intelligence lookup failed — fall through to basic context } // Fallback to basic context if intelligence not available if (!intelligenceUsed) { const events = (plant as any).events || (plant as any).plantEvents || []; const recentMemories = events .filter((e: any) => !e.isDeleted && !e.deletedAt) .sort((a: any, b: any) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime()) .slice(0, 5) .map((e: any) => `${e.title}${e.note ? ': ' + e.note : ''}`); plantContext = { name: plant.name || plant.commonName, scientificName: plant.scientificName || undefined, environment: plant.locationContext || undefined, setup: plant.wateringSchedule || undefined, recentMemories: recentMemories.length > 0 ? recentMemories : undefined, }; } } } // Full plant memory — the AI reads the accumulated history before diagnosing let plantMemoryBlock: string | undefined; if (problemScanLinkedPlantId) { try { const memory = await buildPlantMemoryContext(problemScanLinkedPlantId); if (analysisEpoch !== analysisEpochRef.current) return; plantMemoryBlock = renderMemoryContextBlock(memory) || undefined; } catch { if (analysisEpoch !== analysisEpochRef.current) return; plantMemoryBlock = undefined; // non-fatal } } // Open-case context — the AI reads the prior points in the plant's ongoing // case (if any) so it can connect this scan to what was seen before. let openCaseBlock: string | undefined; if (problemScanLinkedPlantId) { try { openCaseBlock = await renderOpenCaseContext(problemScanLinkedPlantId) || undefined; if (analysisEpoch !== analysisEpochRef.current) return; } catch { if (analysisEpoch !== analysisEpochRef.current) return; openCaseBlock = undefined; // non-fatal } } const result = await analyzeProblem({ imageBase64: base64, additionalImagesBase64: additionalImagesBase64.length > 0 ? additionalImagesBase64 : undefined, symptoms: allProblemScanSymptoms, // Per-photo multimodal breakdown — aligned to image order (primary first, // then each additional photo), with that photo's own symptoms + category. photoSymptoms: buildPhotoSymptoms(), userNote: problemScanNote.trim() || undefined, contextType: problemScanContextType || 'unknownPlant', plantContext, plantMemory: [plantMemoryBlock, openCaseBlock].filter(Boolean).join('\n\n') || undefined, carePreferences, }); if (analysisEpoch !== analysisEpochRef.current) return; setProblemScanResult(result); setProblemScanStep('result'); // NOTE: We do NOT create/persist the investigation here. The case is only // saved when the user taps "Start Plant Investigation" (saveProblemScanToMemories), // which now also creates the investigation. This fixes the bug where every // scan persisted a case even if the user never hit save. // For standalone scans, show the follow-up card immediately if (problemScanContextType === 'unknownPlant') { setProblemScanShowFollowUp(true); } haptic.success(); } catch (error: any) { if (analysisEpoch !== analysisEpochRef.current) return; const message = error?.message || 'Something went wrong. Please try again.'; setProblemScanError(message); setProblemScanStep('result'); if (problemScanContextType === 'unknownPlant') { setProblemScanShowFollowUp(true); } haptic.warning(); } finally { setProblemScanAnalyzing(false); } }; const saveProblemScanToMemories = async () => { const targetPlantId = problemScanLinkedPlantId; if (!targetPlantId) { Alert.alert('No plant selected', 'Problem scans can be saved to a plant in your garden. Try selecting a plant first.'); return; } const plant = savedPlants?.find(p => p.id === targetPlantId); if (!plant) { Alert.alert('Plant not found', 'Could not find the selected plant.'); return; } // ── Create/persist the investigation ONLY on explicit save ───────────── // The scan result has been shown but the case is not created until the user // taps "Start Plant Investigation". This is the fix for investigations being // saved even when the user never hit save. const now = new Date().toISOString(); let createdInvestigationId: string | null = null; if (problemScanResult) { const invPhotos: InvestigationPhoto[] = []; const labeledPhotos = problemImages.length > 0 ? problemImages : (problemScanImage ? [{ uri: problemScanImage, source: 'camera' as const, capturedAt: now }] : []); labeledPhotos.forEach((img) => { invPhotos.push({ uri: img.uri, category: mapProblemLabelToCategory(img.label), addedAt: now }); }); const invTitle = problemScanResult.recommendedMemoryTitle || (allProblemScanSymptoms.length > 0 ? `Investigation: ${allProblemScanSymptoms.map(s => symptomLabelMapLocal[s] || s).join(', ')}` : 'Problem Investigation'); const pointData = { symptoms: allProblemScanSymptoms, notes: problemScanNote.trim() || undefined, photos: invPhotos, firstLook: { summary: problemScanResult.summary, possibleCauses: problemScanResult.possibleCauses, noticed: problemScanResult.noticed, suggestedChecks: problemScanResult.suggestedChecks, confidence: problemScanResult.confidence, uncertaintyNote: problemScanResult.uncertaintyNote, generatedAt: now, }, possibleCauses: problemScanResult.possibleCauses, suggestedChecks: problemScanResult.suggestedChecks, }; const openCase = await getOpenInvestigationForPlant(targetPlantId); if (openCase) { // Append to the plant's existing open case (one case per plant). const updated = await appendPointToInvestigation(openCase.investigationId, pointData); const base = updated || openCase; if (problemScanResult.treatmentPlan) { const withPlan = await setInvestigationTreatmentPlan(openCase.investigationId, problemScanResult.treatmentPlan); if (withPlan) createdInvestigationId = withPlan.investigationId; else createdInvestigationId = base.investigationId; } else { createdInvestigationId = base.investigationId; } setInvestigations(prev => prev.map(inv => inv.investigationId === createdInvestigationId ? (createdInvestigationId === openCase.investigationId ? base : { ...prev.find(x => x.investigationId === createdInvestigationId)!, treatmentPlan: problemScanResult.treatmentPlan }) : inv)); } else { const inv = await createInvestigation({ plantId: targetPlantId, title: invTitle, treatmentPlan: problemScanResult.treatmentPlan, ...pointData, }); createdInvestigationId = inv.investigationId; setInvestigations(prev => [inv, ...prev]); } setCurrentInvestigationId(createdInvestigationId); } const existingEvents = (plant as any).events || (plant as any).plantEvents || []; const nextEvents = [...existingEvents]; // When we have an AI result, create one combined event // When there's no AI result, create a standalone observation event let createdEventId: string | null = null; if (problemScanResult) { const aiEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(targetPlantId), createdAt: now, eventDate: now, type: 'ai_finding', eventCategory: 'ai_finding', title: problemScanResult.recommendedMemoryTitle, note: problemScanResult.recommendedMemoryNotes || problemScanNote.trim() || undefined, photoUri: problemScanImage || undefined, source: 'ai', updatedAt: now, visibility: 'private', trustRelevant: true, resolutionStatus: 'active', }; createdEventId = aiEvent.id; nextEvents.unshift(aiEvent); } else { const observationTitle = allProblemScanSymptoms.length > 0 ? `Observed ${allProblemScanSymptoms.map(s => symptomLabelMapLocal[s] || s).join(', ')}` : 'Problem scan observation'; const observationEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(targetPlantId), createdAt: now, eventDate: now, type: 'pest_observation', eventCategory: 'observation', title: observationTitle, note: problemScanNote.trim() || undefined, photoUri: problemScanImage || undefined, source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'active', }; createdEventId = observationEvent.id; nextEvents.unshift(observationEvent); } // Link memory to investigation if one exists (use the local id — the state // setter is async, so currentInvestigationId may not be updated yet here). if (createdInvestigationId && createdEventId) { linkMemoryToInvestigation( createdInvestigationId, createdEventId, problemScanResult ? 'ai_finding' : 'memory', ).catch(err => { if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[Pixie] linkMemoryToInvestigation failed', err); }); } const sortedEvents = nextEvents.sort( (a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime() ); const patch = { events: sortedEvents, plantEvents: sortedEvents, updatedAt: now, } as Partial; onPlantUpdated(targetPlantId, patch); haptic.success(); if (problemScanContextType === 'unknownPlant') { setProblemScanShowFollowUp(true); } else { Alert.alert('Investigation started', 'Your investigation has been started. You can find it in the Cases tab.', [ { text: 'OK', onPress: () => resetProblemScan() }, ]); } }; const saveProblemScanObservationOnly = () => { const targetPlantId = problemScanLinkedPlantId; if (!targetPlantId) { Alert.alert('No plant selected', 'Select a plant to save this observation.'); return; } const now = new Date().toISOString(); const plant = savedPlants?.find(p => p.id === targetPlantId); const existingEvents = plant ? ((plant as any).events || (plant as any).plantEvents || []) : []; const observationTitle = allProblemScanSymptoms.length > 0 ? `Observed ${allProblemScanSymptoms.map(s => symptomLabelMapLocal[s] || s).join(', ')}` : 'Problem scan observation'; const observationEvent: PlantEvent = { id: createGardenId('event'), plantId: savedGardenKey(targetPlantId), createdAt: now, eventDate: now, type: 'pest_observation', eventCategory: 'observation', title: observationTitle, note: problemScanNote.trim() || undefined, photoUri: problemScanImage || undefined, source: 'user', updatedAt: now, visibility: 'private', trustRelevant: false, resolutionStatus: 'active', }; const sortedEvents = [observationEvent, ...existingEvents].sort( (a, b) => new Date(b.eventDate || b.createdAt).getTime() - new Date(a.eventDate || a.createdAt).getTime() ); const patch = { events: sortedEvents, plantEvents: sortedEvents, updatedAt: now, } as Partial; onPlantUpdated(targetPlantId, patch); haptic.success(); resetProblemScan(); }; const handleProblemScanYes = () => { // Save problem scan context temporarily so we can attach it after Plant ID creates the plant setPendingProblemScanContext({ imageUri: problemScanImage || '', symptoms: allProblemScanSymptoms, userNote: problemScanNote.trim() || undefined, aiResult: problemScanResult || undefined, investigationId: currentInvestigationId || undefined, }); // Don't pass problem photo to Plant ID — it's a leaf/detail shot, not a whole-plant photo. // The image is stored in pendingProblemScanContext and will be attached as a memory after identification. resetProblemScan(); // Launch Plant ID with a clean slate setMode('plant'); }; // ─── Problem Scan Rendering ───────────────────────────────────────────────── if (mode === 'problem' && problemScanStep) { const linkedPlant = problemScanLinkedPlantId ? savedPlants?.find(p => p.id === problemScanLinkedPlantId) : null; // Group the saved plants by space, mirroring the Garden tab's INDOOR/OUTDOOR/space layout // so the picker feels as organized as the garden grid. Compact squares — not a flat list. const pickerPlants = savedPlants || []; const pickerRooms = rooms || []; const plantSpaceId = (plant: SavedPlantProfile) => plant.spaceId || null; const pickerGroups = [ { key: 'indoor', title: 'INDOOR', spaces: pickerRooms .filter(room => isIndoorSpaceType(room.spaceType)) .sort((a, b) => a.name.localeCompare(b.name)) .map(room => ({ title: room.name, plants: pickerPlants.filter(p => plantSpaceId(p) === room.id).sort((a, b) => (a.name || a.commonName || '').localeCompare(b.name || b.commonName || '')), })) .filter(group => group.plants.length > 0), }, { key: 'outdoor', title: 'OUTDOOR', spaces: pickerRooms .filter(room => isOutdoorSpaceType(room.spaceType)) .sort((a, b) => a.name.localeCompare(b.name)) .map(room => ({ title: room.name, plants: pickerPlants.filter(p => plantSpaceId(p) === room.id).sort((a, b) => (a.name || a.commonName || '').localeCompare(b.name || b.commonName || '')), })) .filter(group => group.plants.length > 0), }, { key: 'unassigned', title: 'UNASSIGNED', spaces: [ { title: 'Unassigned', plants: pickerPlants .filter(p => !plantSpaceId(p) || !pickerRooms.some(r => r.id === plantSpaceId(p))) .sort((a, b) => (a.name || a.commonName || '').localeCompare(b.name || b.commonName || '')), }, ].filter(group => group.plants.length > 0), }, ].filter(group => group.spaces.length > 0); const pickerFlatPlants = pickerGroups.flatMap(group => group.spaces.flatMap(space => space.plants)); const pickerGrouped = pickerFlatPlants.length === pickerPlants.length; return ( {/* Header removed — the system back button handles navigation. Removing it lets the content fit without scrolling. */} {/* Step: Context question */} {problemScanStep === 'context' && (
If it is, Pixie can look at the plant's history for better insights. handleProblemScanContextChoice('unknownPlant'))} > Not yet handleProblemScanContextChoice('existingPlant'))} > Yes, choose {/* Plant picker for existing plant */} {problemScanContextType === 'existingPlant' && ( Which plant is it? {pickerPlants.length > 0 ? ( pickerGrouped ? ( {pickerGroups.map(section => ( {section.title} {section.spaces.map(space => ( {space.title} {space.plants.map(plant => { const selected = problemScanLinkedPlantId === plant.id; return ( handleProblemScanPlantSelect(plant.id))} > {plant.photoUri ? ( ) : ( 🌿 )} {plant.name || plant.commonName} {selected ? ( ) : null} ); })} ))} ))} ) : ( {pickerPlants.map(plant => ( handleProblemScanPlantSelect(plant.id))} > {plant.photoUri ? ( ) : ( 🌿 )} {plant.name || plant.commonName} {plant.scientificName ? ( {plant.scientificName} ) : null} {problemScanLinkedPlantId === plant.id ? ( ) : null} ))} ) ) : ( You don't have any plants saved yet. You can identify this plant after the scan. )} )} )} {/* Step: Symptoms */} {problemScanStep === 'symptoms' && (
{linkedPlant ? ( Checking {linkedPlant.name || linkedPlant.commonName} ) : null} {/* Multi-photo thumbnails — tap one to make it the active photo */} {problemScanPhotos.length > 0 && ( {problemScanPhotos.map((uri, idx) => { const isActive = activePhotoUri === uri || (!activePhotoUri && idx === 0); const photoSymptomCount = (problemScanPhotoSymptoms[uri] || []).length; return ( setActivePhotoUri(uri))} style={{ position: 'relative' }} > {photoSymptomCount} {isActive ? ( {idx === 0 ? 'CONCERN' : 'PHOTO'} ) : null} ); })} {problemScanPhotos.length < 5 && ( + )} )} {/* Shared green panel — the active photo + its chips grouped together */} {problemScanPhotos.length > 0 ? ( {problemScanPhotos.length > 1 ? `Photo ${(problemScanPhotos.indexOf(activePhotoUri || problemScanPhotos[0]) + 1)}` : 'This photo'} What's wrong here? ) : ( <> What are you noticing? )} Anything else to share? (optional) Ask Pixie )} {/* Step: Analyzing */} {problemScanStep === 'analyzing' && ( Pixie is looking... She's examining the photo and thinking about what might be going on. )} {/* Step: Result */} {problemScanStep === 'result' && ( <> {/* Error state */} {problemScanError && !problemScanResult && ( <> Pixie couldn't get a clear read this time, but we can still save what you noticed. {problemScanError} { resetProblemScan(); changeMode(); })} > New Scan Resubmit {problemScanLinkedPlantId ? ( Save Observation Only ) : null} {/* Follow-up for unknown plants even on error */} {problemScanShowFollowUp && ( Would you like to identify this plant and add it to your Garden? Pixie will carry your scan details forward — no need to rescan. { resetProblemScan(); changeMode(); })} > Not Now Yes )} )} {/* Success state */} {problemScanResult && ( <> {problemScanResult.summary} {/* What Pixie noticed */} {problemScanResult.noticed.length > 0 && ( What Pixie noticed {problemScanResult.noticed.map((item, i) => ( {item} ))} )} {/* Possible causes */} {problemScanResult.possibleCauses.length > 0 && ( Possible causes {problemScanResult.possibleCauses.map((item, i) => ( {item} ))} )} {/* Things to check next */} {problemScanResult.suggestedChecks.length > 0 && ( Things to check next {problemScanResult.suggestedChecks.map((item, i) => ( {item} ))} )} {/* Safety / treatment warnings */} {problemScanResult.treatmentWarnings && problemScanResult.treatmentWarnings.length > 0 && ( ⚠️ Safety notes {problemScanResult.treatmentWarnings.map((item, i) => ( {item} ))} )} {/* Confidence */} Confidence:{' '} {problemScanResult.confidence === 'high' ? 'Pixie feels fairly sure about this' : problemScanResult.confidence === 'medium' ? 'Pixie has some ideas but is not certain' : "Pixie is sharing her best guess — she'd need more info to be sure"} {/* Uncertainty note */} {problemScanResult.uncertaintyNote ? ( {problemScanResult.uncertaintyNote} ) : null} {/* Follow-up for unknown plants */} {problemScanShowFollowUp && ( Would you like to identify this plant and add it to your Garden? Pixie will carry your scan details forward — no need to rescan. { resetProblemScan(); changeMode(); })} > Not Now Yes )} {/* Action buttons */} {!problemScanShowFollowUp && ( Start Plant Investigation { resetProblemScan(); changeMode(); })} > Scan Again )} )} )} {problemScanStep === 'analyzing' && ( <> {/* Scattered flashing sparkles over the green void (animated) */} {sparklePositions.map((sp, i) => ( ))} {/* The fairy */} )} { setShowPhotoPicker(false); void captureAdditionalPhoto(); }} onChoosePhoto={() => { setShowPhotoPicker(false); void pickAdditionalPhoto(); }} onCancel={() => setShowPhotoPicker(false)} /> ); } if (mode === 'plant' || mode === 'fungus' || mode === 'seedTag') { return ( ); } return ( {cameraGranted ? ( ) : ( )} {!mode ? ( ) : ( <> {cameraPermission && !cameraGranted ? ( Camera access needed Allow camera access to use live Identify capture. Allow camera ) : null} Mode: {selectedMode?.title} Change {selectedMode?.instruction} {mode === 'problem' && problemImages.length > 0 ? ( Capture each affected area on the same plant. Add more photos if there is more than one issue. {problemImages.length} photos added {problemImages.map((image, index) => ( {index + 1} {problemLabels.map(label => ( setProblemLabel(index, label))} activeOpacity={0.8}> {label} ))} ))} Add another area Analyze together ) : null} {lastRequest ? ( {lastRequest.images.length} {lastRequest.images.length === 1 ? 'photo' : 'photos'} ready for {lastRequest.mode === 'seedTag' ? 'Seed / Tag' : selectedMode?.title} analysis. ) : null} setTorchOn(current => !current))}> )} { setShowPhotoPicker(false); void captureAdditionalPhoto(); }} onChoosePhoto={() => { setShowPhotoPicker(false); void pickAdditionalPhoto(); }} onCancel={() => setShowPhotoPicker(false)} /> ); } const s = StyleSheet.create({ labelChipTextActive: { color: '#fff' }, problemScanListText: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', flex: 1 }, cameraSideButtonActive: { backgroundColor: '#E8F5D3', borderColor: green }, problemScanBodyText: { color: '#5E5845', fontSize: 14, lineHeight: 20, fontWeight: '600' }, problemScanPlantList: { gap: 8, marginTop: 8 }, problemScanSectionTitle: { color: '#78694B', fontSize: 13, fontWeight: '900', marginBottom: 6, marginTop: 4 }, formLabel: { color: dark, fontSize: 13, fontWeight: '900', marginTop: 4, marginBottom: 6 }, identifyScreen: { flex: 1, backgroundColor: '#FDF4DB' }, modeIconImage: { width: 46, height: 46 }, problemScanPreviewImage: { width: '100%', height: 200, borderRadius: 16, marginBottom: 16, backgroundColor: '#1a3a1a' }, helperMascotContainer: { position: 'absolute', left: '5%', width: '60%', aspectRatio: 540 / 296, zIndex: 24, overflow: 'hidden' }, helperMascot: { width: '100%', height: undefined, aspectRatio: 540 / 296 }, modeTitle: { fontSize: 17, fontWeight: '900', color: dark }, comingSoonText: { color: green, fontSize: 11, fontWeight: '900' }, addAreaText: { color: green, fontSize: 12, fontWeight: '900' }, problemScanListBullet: { color: '#5B7553', fontSize: 13, fontWeight: '900', marginRight: 6 }, modeCardStack: { gap: 10 }, problemScanConfidenceValue: { color: '#5E5845', fontWeight: '600' }, // Investigation detail view styles identifyInstruction: { flexShrink: 1, color: '#FFFDF7', fontSize: 14, lineHeight: 19, fontWeight: '900', textAlign: 'center', textShadowColor: 'rgba(0,0,0,0.45)', textShadowRadius: 5 }, problemSubtext: { fontSize: 11, lineHeight: 15, fontWeight: '700', color: '#5D5A4E', marginTop: 2, maxWidth: 220 }, problemScanAnalyzingTitle: { color: '#5B7553', fontSize: 16, fontWeight: '800' }, problemScanAnalyzingCard: { marginTop: 90, marginBottom: 20 }, problemScanAnalyzingFairy: { width: 300, height: 165, aspectRatio: 540 / 296 }, problemScanAnalyzingFairyOverlay: { position: 'absolute', left: '6%', bottom: 150, width: '88%', alignItems: 'flex-start', zIndex: 20 }, problemScanSpinnerOverlay: { position: 'absolute', top: '42%', left: 0, width: '100%', alignItems: 'center', zIndex: 25 }, problemScanSpinner: { width: 72, height: 72, marginBottom: 4 }, sparkleWrap: { position: 'absolute', zIndex: 22 }, sparkleText: { color: '#FFD700', textShadowColor: 'rgba(255,215,0,0.6)', textShadowRadius: 4 }, cameraFallback: { ...StyleSheet.absoluteFillObject, backgroundColor: '#143716' }, countChip: { borderRadius: 999, backgroundColor: '#E8F5D3', paddingHorizontal: 9, paddingVertical: 5 }, analyzeButton: { flex: 1, minHeight: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: green }, modeSubtitle: { fontSize: 12, lineHeight: 16, fontWeight: '700', color: '#5D5A4E', marginTop: 2 }, problemScanSection: { marginTop: 12 }, permissionTitle: { fontSize: 20, fontWeight: '900', color: dark, marginTop: 8 }, cameraSideButtonImage: { width: 58, height: 58 }, problemScanConfidenceLabel: { color: '#5B7553', fontSize: 12, fontWeight: '700' }, problemScanPlantThumb: { width: 36, height: 36, borderRadius: 8 }, modeSubtitleDisabled: { color: '#817A68' }, buttonText: { color: '#fff', fontSize: 15, fontWeight: '900', textAlign: 'center' }, cameraControls: { position: 'absolute', bottom: 244, left: 34, right: 34, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', zIndex: 22 }, instructionLeaf: { width: 18, height: 18, opacity: 0.92 }, problemScanScrollView: { flex: 1 }, button: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, marginTop: 8 }, buttonDisabled: { opacity: 0.55 }, labelChip: { borderRadius: 999, borderWidth: 1, borderColor: '#BFD9B4', backgroundColor: '#FFF9EE', paddingHorizontal: 7, paddingVertical: 3 }, cameraVeil: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(9,30,12,0.18)' }, photoTrayItem: { width: 92 }, modeCardDisabled: { opacity: 0.58 }, identifyPickerPanel: { width: '100%', maxWidth: 420, borderRadius: 24, borderWidth: 1, borderColor: 'rgba(120,198,106,0.58)', backgroundColor: 'rgba(255,253,247,0.96)', padding: 18, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 18, elevation: 9, overflow: 'hidden' }, problemScanListItem: { color: '#5E5845', fontSize: 13, lineHeight: 18, fontWeight: '600', marginLeft: 8, marginBottom: 3 }, formSecondaryButtonText: { color: dark, fontSize: 15, fontWeight: '900', textAlign: 'center' }, problemScanUncertainty: { color: '#78694B', fontSize: 12, lineHeight: 16, fontWeight: '600', fontStyle: 'italic', marginTop: 6 }, modeCopy: { flex: 1, minWidth: 0 }, countChipText: { color: green, fontSize: 11, fontWeight: '900' }, permissionPanel: { alignSelf: 'center', marginTop: 118, width: '88%', borderRadius: 23, borderWidth: 1, borderColor: 'rgba(120,198,106,0.58)', backgroundColor: 'rgba(255,253,247,0.96)', alignItems: 'center', padding: 18, zIndex: 26 }, problemPanelHeader: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }, modeIconDisabled: { backgroundColor: '#F1EDDD' }, modeIconImageDisabled: { opacity: 0.62 }, captureButtonImage: { width: 76, height: 76 }, identifyPickerOverlay: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18, paddingBottom: 98 }, problemScanScrollContent: { paddingHorizontal: 18, paddingTop: 60, paddingBottom: 160 }, 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 }, problemScanButtonRow: { flexDirection: 'row', gap: 10, marginTop: 8 }, problemScanPlantScientific: { color: '#5B7553', fontSize: 11, fontWeight: '600', fontStyle: 'italic' }, cameraPreview: { ...StyleSheet.absoluteFillObject }, problemPanel: { position: 'absolute', left: 12, right: 12, bottom: 204, borderRadius: 18, backgroundColor: 'rgba(255,253,247,0.94)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', padding: 10 }, photoTray: { gap: 8, paddingTop: 9, paddingBottom: 5 }, handoffToast: { position: 'absolute', left: 20, right: 20, bottom: 206, borderRadius: 18, backgroundColor: 'rgba(255,253,247,0.95)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.9)', paddingHorizontal: 12, paddingVertical: 10 }, problemScanPlantName: { color: '#2D4A2D', fontSize: 14, fontWeight: '800' }, problemTitle: { fontSize: 13, lineHeight: 17, fontWeight: '900', color: dark, maxWidth: 205 }, invDetailSpacer: { width: 60 }, labelChipActive: { backgroundColor: green, borderColor: green }, viewfinder: { position: 'absolute', top: 294, alignSelf: 'center', width: 286, height: 342 }, identifySafe: { flex: 1 }, permissionButtonText: { color: '#fff', fontWeight: '900' }, formButtonRow: { flexDirection: 'row', gap: 12, marginTop: 7 }, problemScanBackText: { color: '#5B7553', fontSize: 14, fontWeight: '800' }, changeText: { color: green, fontSize: 14, fontWeight: '900', textDecorationLine: 'underline' }, modePill: { position: 'absolute', top: 58, alignSelf: 'center', minHeight: 48, borderRadius: 24, backgroundColor: 'rgba(248,244,232,0.97)', borderWidth: 1, borderColor: 'rgba(191,217,180,0.9)', flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 8, elevation: 5 }, modePillText: { color: '#2F302A', fontSize: 14, fontWeight: '800' }, problemScanBackButton: { paddingVertical: 6, paddingRight: 12 }, pickerTitle: { fontSize: 21, lineHeight: 26, fontWeight: '900', color: dark, textAlign: 'center', marginHorizontal: 12 }, labelChipText: { fontSize: 9, fontWeight: '800', color: '#5D5A4E' }, problemScanHeaderTitle: { color: '#2D4A2D', fontSize: 18, fontWeight: '900', flex: 1 }, modeIcon: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E8F5D3' }, problemActions: { flexDirection: 'row', gap: 8, marginTop: 4 }, problemScanPlantPicker: { marginTop: 12 }, labelTray: { gap: 4, paddingTop: 5 }, addAreaButton: { flex: 1, minHeight: 38, borderRadius: 19, borderWidth: 1, borderColor: '#BFD9B4', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FFF9EE' }, pickerSubtitle: { fontSize: 13, lineHeight: 18, fontWeight: '700', color: '#68705C', textAlign: 'center', marginTop: 7, marginBottom: 15 }, cameraSideButton: { width: 58, height: 58, borderRadius: 29, alignItems: 'center', justifyContent: 'center' }, photoNumber: { position: 'absolute', top: 4, left: 4, width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', backgroundColor: green }, problemScanResultSummary: { color: '#2D4A2D', fontSize: 15, fontWeight: '800', lineHeight: 22, marginBottom: 12 }, problemScanConfidence: { color: '#5B7553', fontSize: 12, fontWeight: '700', marginTop: 4 }, problemScanHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, paddingTop: 20 }, instructionWrap: { position: 'absolute', top: 121, left: 20, right: 20, alignItems: 'center', justifyContent: 'center' }, photoNumberText: { color: '#fff', fontSize: 11, fontWeight: '900' }, photoThumb: { width: 92, height: 68, borderRadius: 13, backgroundColor: '#DDE7D4' }, analyzeText: { color: '#fff', fontSize: 12, fontWeight: '900' }, permissionText: { fontSize: 13, lineHeight: 18, fontWeight: '700', color: '#5D5A4E', textAlign: 'center', marginVertical: 10 }, handoffText: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '900', textAlign: 'center' }, problemScanAnalyzingWrap: { alignItems: 'center', gap: 12, paddingVertical: 32 }, captureButton: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', shadowOpacity: 0.22, shadowRadius: 12, elevation: 8 }, formSecondaryButton: { flex: 1, minHeight: 48, borderRadius: 999, borderWidth: 1, borderColor: '#6B8E6B', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, problemScanSecondarySpacer: { marginTop: 12 }, modeCard: { minHeight: 82, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFF9EE', flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 13, paddingVertical: 10 }, problemScanPlantItem: { flexDirection: 'row', alignItems: 'center', gap: 10, borderRadius: 12, borderWidth: 1, borderColor: '#D6E7BE', backgroundColor: '#F3F7E6', padding: 10 }, modeTitleDisabled: { color: '#706A58' }, problemScanPlantItemSelected: { borderColor: '#5B7553', backgroundColor: '#E8F0E4' }, // Space-grouped compact square grid (mirrors the Garden tab's grid feel) problemScanSpaceGroups: { gap: 4, marginTop: 6 }, problemScanSpaceGroup: { width: '100%', marginBottom: 2 }, problemScanSpaceGroupTitle: { color: green, fontSize: 13, lineHeight: 17, fontWeight: '900', marginBottom: 6, paddingHorizontal: 3, marginTop: 8 }, problemScanPlantSpaceSubgroup: { width: '100%', marginBottom: 6 }, problemScanPlantSpaceSubgroupTitle: { color: dark, fontSize: 12, lineHeight: 16, fontWeight: '800', marginBottom: 6, paddingHorizontal: 3 }, problemScanPlantGrid: { width: '100%', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start', gap: 8 }, // FIXED tile size rule: every tile is exactly this width, no flexGrow/flexBasis. // A space with ONE plant still renders one small tile — it never stretches to fill the row. problemScanPlantSquare: { width: '30%', borderRadius: 12, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', padding: 5, shadowColor: '#000', shadowOpacity: 0.07, shadowRadius: 7, shadowOffset: { width: 0, height: 2 }, elevation: 2, marginBottom: 6, }, problemScanPlantSquareSelected: { borderColor: '#2E6B3F', borderWidth: 2, backgroundColor: '#EAF5DC' }, problemScanPlantSquareImg: { width: '100%', aspectRatio: 1, borderRadius: 9, backgroundColor: '#E8F5D3' }, problemScanPlantSquarePlaceholder: { alignItems: 'center', justifyContent: 'center' }, problemScanPlantSquareName: { color: '#2D4A2D', fontSize: 10, lineHeight: 13, fontWeight: '700', textAlign: 'center', marginTop: 4, flexShrink: 1 }, problemScanPlantSquareCheck: { position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: 9, backgroundColor: '#2E6B3F', alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: '#fff', }, modeTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, invDetailTimelineContent: { flex: 1 }, problemScanSafeArea: { flex: 1, backgroundColor: '#102A10' }, permissionButton: { height: 46, borderRadius: 23, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18, alignSelf: 'stretch', marginTop: 4 }, });