/**
* 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 },
});