/** * CareMetricsModal — the photo-update loop UI. * * After a care task is completed (or on a scheduled check-in), this modal * offers a quick progress photo. The AI reads the photo + the plant's context * and returns updated care metrics / schedule deltas. The user sees Pixie's * assessment and whether the care schedule changed. * * ALWAYS skippable — completing a care task never blocks on this. */ import React, { useState } from 'react'; import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import { green, dark, line, haptic, pressWithHaptic } from '../constants/theme'; import type { CareContextPacket, CareSchedule } from '../types/care'; import { updateCareMetricsFromPhoto, type CareMetricsUpdate } from '../services/care'; interface CareMetricsModalProps { visible: boolean; plantName: string; scientificName?: string; contextPacket: CareContextPacket; currentSchedule?: CareSchedule; plantMemory?: string; onCancel: () => void; /** Called with the AI's assessment + updated schedule so the caller can apply it. */ onApplied: (update: CareMetricsUpdate) => void; } export function CareMetricsModal({ visible, plantName, scientificName, contextPacket, currentSchedule, plantMemory, onCancel, onApplied, }: CareMetricsModalProps) { const [photoUri, setPhotoUri] = useState(null); const [photoBase64, setPhotoBase64] = useState(null); const [busy, setBusy] = useState(false); const [showPicker, setShowPicker] = useState(false); const [result, setResult] = useState(null); // Pixel-based card height (Bekky rule #2 — no percentage maxHeight on modal cards). const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const cardMaxHeight = Math.max(320, windowHeight - (insets.top + 24) - (insets.bottom + 24)); // The subtitle now sits OUTSIDE the ScrollView (so it can't be clipped), but it // still consumes card height. Reserve room for up to 3 lines of subtitle text // (long plant names wrap) so the card never clips the sentence's last line. const SUBTITLE_RESERVE = 76; const scrollMaxHeight = Math.max(180, cardMaxHeight - 118 - SUBTITLE_RESERVE); const reset = () => { setPhotoUri(null); setPhotoBase64(null); setShowPicker(false); setBusy(false); setResult(null); }; const close = () => { reset(); onCancel(); }; const pickFromLibrary = async () => { try { const res = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], base64: true, quality: 0.5, }); if (!res.canceled && res.assets?.[0]) { setPhotoUri(res.assets[0].uri); setPhotoBase64(res.assets[0].base64 || null); } setShowPicker(false); } catch { setShowPicker(false); } }; const takePhoto = async () => { try { const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { setShowPicker(false); return; } const res = await ImagePicker.launchCameraAsync({ base64: true, quality: 0.5, }); if (!res.canceled && res.assets?.[0]) { setPhotoUri(res.assets[0].uri); setPhotoBase64(res.assets[0].base64 || null); } setShowPicker(false); } catch { setShowPicker(false); } }; const submit = async () => { if (busy || !photoBase64) return; setBusy(true); try { const update = await updateCareMetricsFromPhoto({ photoUri: photoUri || '', plantName, scientificName, contextPacket, currentSchedule, plantMemory, }); if (update) { setResult(update); onApplied(update); } else { haptic.warning(); setBusy(false); } } catch { haptic.warning(); setBusy(false); } }; return ( 📸 Quick Check-In {/* No red ✕ close pill (Bekky, 2026-08-18) — "Skip for now" is the dismiss. A red X reads as an error and is redundant. */} {result ? ( Pixie's assessment {result.summary} {result.changed ? ( ✨ Care schedule updated ) : ( 🌿 Care schedule unchanged )} {result.memoryNote ? ( 🧠 {result.memoryNote} ) : null} Done ) : ( <> Snap a quick photo of {plantName} so Pixie can see how it's doing and adjust its care. {photoUri ? ( setShowPicker(true))}> 📷 Retake ) : ( setShowPicker(true))}> 📷 Add a photo )} {busy ? ( ) : ( Let Pixie look )} Skip for now )} {/* Photo source picker */} setShowPicker(false)}> setShowPicker(false)}> Add a Photo 📷 Take Photo 🖼️ Choose Photo setShowPicker(false))}> Cancel ); } const s = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.6)', justifyContent: 'center', alignItems: 'center', padding: 20, }, card: { width: '100%', maxWidth: 360, maxHeight: '85%', backgroundColor: '#FFFCF5', borderRadius: 24, padding: 20, shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 20, elevation: 12, }, headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, title: { color: dark, fontSize: 19, fontWeight: '900', flex: 1 }, subtitle: { color: '#5D5A4E', fontSize: 13, lineHeight: 18, marginTop: 6, marginBottom: 12, // Always reserve room for up to 3 lines (3 × 18 lineHeight = 54px) so a long // plant name can wrap to a third line without the card clipping the sentence. // Short names simply get a little extra breathing room below the text. minHeight: 54, flexShrink: 0, }, scroll: { flexGrow: 0 }, scrollContent: { gap: 12, paddingBottom: 4 }, photo: { width: '100%', height: 140, borderRadius: 14, backgroundColor: '#E8F5D3' }, retakePill: { alignSelf: 'flex-start', minHeight: 34, borderRadius: 999, borderWidth: 1.5, borderColor: line, backgroundColor: '#FFF9EE', paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center', marginTop: 8 }, retakeText: { color: dark, fontSize: 12, fontWeight: '700' }, addPhotoTile: { minHeight: 80, borderRadius: 14, borderWidth: 1.5, borderColor: '#BFD9B4', backgroundColor: '#FFF9EE', alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' }, addPhotoText: { color: green, fontSize: 14, fontWeight: '700' }, submitButton: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginTop: 6 }, submitDisabled: { opacity: 0.5 }, submitText: { color: '#fff', fontSize: 15, fontWeight: '900' }, skipButton: { minHeight: 40, borderRadius: 999, borderWidth: 1.5, borderColor: line, backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center', marginTop: 4 }, skipText: { color: dark, fontSize: 13, fontWeight: '700' }, resultTitle: { color: dark, fontSize: 16, fontWeight: '900', marginBottom: 4 }, resultBody: { color: '#5D5A4E', fontSize: 14, lineHeight: 20 }, changedBadge: { backgroundColor: '#E8F5D3', borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' }, changedText: { color: green, fontSize: 13, fontWeight: '800' }, sameBadge: { backgroundColor: '#F3F7E6', borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8, alignSelf: 'flex-start' }, sameText: { color: '#5B7553', fontSize: 13, fontWeight: '800' }, memoryNote: { color: '#5D5A4E', fontSize: 12, fontStyle: 'italic' }, doneButton: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginTop: 6 }, doneText: { color: '#fff', fontSize: 15, fontWeight: '900' }, pickerOverlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.6)', justifyContent: 'center', alignItems: 'center', padding: 24 }, pickerCard: { width: '100%', maxWidth: 340, backgroundColor: '#FFFCF5', borderRadius: 24, padding: 24 }, pickerTitle: { color: dark, fontSize: 18, fontWeight: '800', textAlign: 'center', marginBottom: 16 }, pickerOption: { minHeight: 48, borderRadius: 999, backgroundColor: green, alignItems: 'center', justifyContent: 'center', marginBottom: 10 }, pickerOptionText: { color: '#fff', fontSize: 15, fontWeight: '900' }, pickerCancel: { minHeight: 44, borderRadius: 999, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center' }, pickerCancelText: { color: '#8E2F1E', fontSize: 14, fontWeight: '900' }, });