/** * Settings Panel components for PixieSprout. * SettingsChip, SettingsSection, SettingsPanel. */ import React, { useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DateTimePickerAndroid } from '@react-native-community/datetimepicker'; import type { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as Location from 'expo-location'; import { green, dark, pressWithHaptic } from '../constants/theme'; import { defaultAppSettings } from '../services/gardenStorage'; import { geocodeCityZip, getSeasonProfile } from '../services/weather'; // EdgeModal — RN's Modal types don't include navigationBarTranslucent/statusBarTranslucent // (added at runtime by react-native-edge-to-edge). Cast once, use everywhere. const EdgeModal = Modal as any; import type { AppSettings, ChildAgeRange, PetAccessMode, PetType, RoomProfile, WishlistVisibility, GpsPrecision, PrivacyPreferences, GardenLocationMode, } from '../types/garden'; // ---- Constants ---- const petTypeLabels: Record = { cat: 'Cat', dog: 'Dog', bird: 'Bird', rabbit: 'Rabbit', other: 'Other', }; const childAgeRangeLabels: Record = { baby_toddler: 'Baby/Toddler', young_child: 'Young Child', older_child_teen: 'Older Child/Teen', }; const wishlistVisibilityLabels: Record = { private: 'Private', friends: 'Friends', community: 'Community', }; const petAccessLabels: Record = { whole_home: 'Whole home', specific_spaces: 'Specific spaces', }; // ---- Helpers ---- function getReminderDate(value?: string | null) { const { hour, minute } = parseReminderTime(value); const date = new Date(); date.setHours(hour, minute, 0, 0); return date; } function formatReminderTime(date: Date) { return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } function getBirthdayDate(value?: string | null) { if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) { const parsed = new Date(`${value}T12:00:00`); if (!Number.isNaN(parsed.getTime())) return parsed; } return new Date(2000, 0, 1, 12, 0, 0, 0); } function formatBirthdayValue(date: Date) { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); const day = `${date.getDate()}`.padStart(2, '0'); return `${year}-${month}-${day}`; } function formatBirthdayDisplay(value?: string | null) { if (!value) return 'Optional'; const date = getBirthdayDate(value); return date.toLocaleDateString([], { year: 'numeric', month: 'long', day: 'numeric' }); } function parseReminderTime(value?: string | null) { const text = (value || defaultAppSettings.reminderPreferences.afterWorkReminderTime || '5:30 PM').trim(); const amPmMatch = text.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)$/i); if (amPmMatch) { let hour = Number(amPmMatch[1]); const minute = Number(amPmMatch[2] || 0); const meridiem = amPmMatch[3].toUpperCase(); if (meridiem === 'PM' && hour < 12) hour += 12; if (meridiem === 'AM' && hour === 12) hour = 0; return { hour, minute }; } const clockMatch = text.match(/^(\d{1,2})(?::(\d{2}))?$/); if (clockMatch) return { hour: Number(clockMatch[1]), minute: Number(clockMatch[2] || 0) }; return { hour: 17, minute: 30 }; } // ---- SettingsChip ---- export function SettingsChip({ label, selected, onPress, disabled, }: { label: string; selected?: boolean; onPress?: () => void; disabled?: boolean; }) { return ( {label} ); } // ---- SettingsSection ---- export function SettingsSection({ title, children, note, }: { title: string; children: React.ReactNode; note?: string; }) { return ( {title} {note ? {note} : null} {children} ); } // ---- SettingsPanel ---- export function SettingsPanel({ visible, settings, rooms, onChange, onClose, onOpenPhotoIntelDebug, onGardenLocationSet, careRefreshing, }: { visible: boolean; settings: AppSettings; rooms: RoomProfile[]; onChange: (settings: AppSettings) => void; onClose: () => void; onOpenPhotoIntelDebug?: () => void; /** Fired when the user sets/updates their garden location (Bekky, 2026-08-23) — * the parent refreshes the garden's care guidance. */ onGardenLocationSet?: () => void; /** True while the garden's care guidance is refreshing in the background * (Bekky, 2026-08-23) — shows an "Updating care…" indicator. */ careRefreshing?: boolean; }) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const overlayTopPadding = Math.max(insets.top, 16) + 8; const overlayBottomPadding = Math.max(insets.bottom, 16) + 24; const panelMaxHeight = Math.max(320, windowHeight - overlayTopPadding - overlayBottomPadding); const scrollMaxHeight = Math.max(220, panelMaxHeight - 104); const bottomPadding = 96 + Math.max(insets.bottom, 0); const updateSettings = (patch: Partial) => onChange({ ...settings, ...patch }); const updateHouseholdSafety = (patch: Partial) => updateSettings({ householdSafety: { ...settings.householdSafety, ...patch }, }); const updateProfileCommunity = (patch: Partial) => updateSettings({ profileCommunity: { ...settings.profileCommunity, ...patch }, }); const updatePrivacy = (patch: Partial) => updateSettings({ privacy: { ...settings.privacy, ...patch }, }); const updateCarePreferences = (patch: Partial) => updateSettings({ carePreferences: { ...settings.carePreferences, ...patch }, }); const updateReminderTime = (afterWorkReminderTime: string) => updateSettings({ reminderPreferences: { ...settings.reminderPreferences, afterWorkReminderTime }, }); const togglePetType = (type: PetType) => { const current = settings.householdSafety.petTypes; updateHouseholdSafety({ petTypes: current.includes(type) ? current.filter(item => item !== type) : [...current, type], }); }; const toggleChildAgeRange = (range: ChildAgeRange) => { const current = settings.householdSafety.childAgeRanges; updateHouseholdSafety({ childAgeRanges: current.includes(range) ? current.filter(item => item !== range) : [...current, range], }); }; const togglePetAccessSpace = (spaceId: string) => { const current = settings.householdSafety.petAccessSpaceIds; updateHouseholdSafety({ petAccessSpaceIds: current.includes(spaceId) ? current.filter(id => id !== spaceId) : [...current, spaceId], }); }; const openAfterWorkPicker = () => { DateTimePickerAndroid.open({ value: getReminderDate(settings.reminderPreferences.afterWorkReminderTime), mode: 'time', display: 'spinner', is24Hour: false, onChange: (event: DateTimePickerEvent, selectedDate?: Date) => { if (event.type !== 'set' || !selectedDate) return; updateReminderTime(formatReminderTime(selectedDate)); }, }); }; const openBirthdayPicker = () => { DateTimePickerAndroid.open({ value: getBirthdayDate(settings.profileCommunity.birthday), mode: 'date', display: 'calendar', maximumDate: new Date(), minimumDate: new Date(1900, 0, 1), onChange: (event: DateTimePickerEvent, selectedDate?: Date) => { if (event.type !== 'set' || !selectedDate) return; updateProfileCommunity({ birthday: formatBirthdayValue(selectedDate) }); }, }); }; return ( Settings Personalization foundations for reminders, safety, privacy, and community. Close {/* WEATHER section REMOVED (Bekky, 2026-09-08): weather alerts live ONLY in the notification bell — the Settings copy was a duplicate. */} After Work Reminder Time {settings.reminderPreferences.afterWorkReminderTime || defaultAppSettings.reminderPreferences.afterWorkReminderTime || '5:30 PM'} Change Notification quick actions use this time for Remind after work. Has pets updateHouseholdSafety({ hasPets: true })} /> updateHouseholdSafety({ hasPets: false, petTypes: [], petAccessSpaceIds: [] })} /> {settings.householdSafety.hasPets ? ( <> Pet type(s) {(Object.keys(petTypeLabels) as PetType[]).map(type => ( togglePetType(type)} /> ))} Pet access {(Object.keys(petAccessLabels) as PetAccessMode[]) .filter(mode => mode !== 'specific_spaces' || rooms.length > 0) .map(mode => ( updateHouseholdSafety({ petAccess: mode })} /> ))} {settings.householdSafety.petAccess === 'specific_spaces' ? ( <> Spaces pets can access {rooms.length ? rooms.map(room => ( togglePetAccessSpace(room.id)} /> )) : Add plants to spaces in your Garden to connect room-level safety context.} ) : null} ) : null} Has children updateHouseholdSafety({ hasChildren: true })} /> updateHouseholdSafety({ hasChildren: false, childAgeRanges: [] })} /> {settings.householdSafety.hasChildren ? ( <> Child age range(s) {(Object.keys(childAgeRangeLabels) as ChildAgeRange[]).map(range => ( toggleChildAgeRange(range)} /> ))} ) : null} Birthday {formatBirthdayDisplay(settings.profileCommunity.birthday)} Pick date Tip: tap the year in the calendar to change it quickly. {settings.profileCommunity.birthday ? ( updateProfileCommunity({ birthday: null }))} accessibilityRole="button" accessibilityLabel="Clear birthday"> Clear birthday ) : null} Wishlist visibility {(Object.keys(wishlistVisibilityLabels) as WishlistVisibility[]).map(visibility => ( updateProfileCommunity({ wishlistVisibility: visibility })} /> ))} Community gifting preferences will live here when wishlist features come online. {/* Scan GPS (Bekky, 2026-09-08): a single place to set the scan location precision + remember-my-choice, so people can edit it all in one place and feel confident about what it means. The scan flow reads these same settings. Wrapped in the same green box as Garden Location (Bekky, 2026-09-08). */} Location precision updatePrivacy({ gpsPrecision: 'rough' })} /> updatePrivacy({ gpsPrecision: 'precise' })} /> updatePrivacy({ gpsPrecision: 'skip' })} /> {settings.privacy.gpsPrecision === 'precise' ? 'Exact coordinates. Best when you want to remember exactly where you found something.' : settings.privacy.gpsPrecision === 'skip' ? 'No GPS — you enter a city or ZIP manually on each scan.' : 'Rounded to about a mile. Good for most scans — enough to remember the general area.'} updatePrivacy({ rememberGpsPrecision: !settings.privacy.rememberGpsPrecision })} /> When on, Pixie remembers your scan GPS choice so you don't have to pick it every time. You can still change it on any individual scan — that's a one-time override just for that scan. Take a Walk / Take a Ride use precise GPS only while the excursion is active, then revert to your normal setting. updateCarePreferences({ organicFirst: !settings.carePreferences.organicFirst })} /> updateCarePreferences({ petSafeCaution: !settings.carePreferences.petSafeCaution })} /> updateCarePreferences({ ediblePlantCaution: !settings.carePreferences.ediblePlantCaution })} /> updateCarePreferences({ shoppingStyle: settings.carePreferences.shoppingStyle === 'diy_first' ? 'either' : 'diy_first' })} /> updateCarePreferences({ shoppingStyle: settings.carePreferences.shoppingStyle === 'store_bought' ? 'either' : 'store_bought' })} /> These preferences guide Pixie's care advice and plant identification. Organic-first, kid-safe, and pet-safe choices refine what she recommends. {/* PhotoIntel debug screen — COMMENTED OUT (2026-08-15). Not in use; rewire before re-enabling. {onOpenPhotoIntelDebug && ( 🔬 Photo Intel Debug Test MiMo's data extraction quality on environment, placement, and setup photos. )} */} ); } // ---- Garden Location Section (Bekky, 2026-08-23) ---- // The persistent location of the user's PHYSICAL GARDEN, used for weather-enhanced // care + real-time alerts. SEPARATE from scan GPS (ephemeral, for identifying // plants anywhere). Fetched ONCE on user tap, persisted, user-updatable — never // auto re-fetched, so it can't drift to wherever the user happens to be. const gardenLocationLabels: Record = { precise: 'Precise location', rough: 'Rough area (~1 sq mile)', city_zip: 'City or ZIP', skip: 'Skip location', }; const gardenLocationDescriptions: Record = { precise: 'Exact coordinates. Best for extreme microclimates — coastal bluff, mountain valley, urban heat island.', rough: 'Rounded to about a mile. Great for most gardens — enough for climate, season, and general sun patterns.', city_zip: 'Regional weather only. Coarsest precision — no microclimate detail.', skip: 'No location shared. Pixie still helps identify and guide you, but without weather-enhanced care or real-time alerts.', }; function GardenLocationSection({ privacy, updatePrivacy, onGardenLocationSet, careRefreshing }: { privacy: PrivacyPreferences; updatePrivacy: (patch: Partial) => void; onGardenLocationSet?: () => void; careRefreshing?: boolean }) { const [capturing, setCapturing] = useState(false); const [geocoding, setGeocoding] = useState(false); const [cityZipText, setCityZipText] = useState(privacy.gardenLocation?.mode === 'city_zip' ? privacy.gardenLocation?.label || '' : ''); const [error, setError] = useState(null); const gardenLocation = privacy.gardenLocation; const setGardenLocation = async (loc: NonNullable) => { // Fetch this location's season profile (hot/cold months) from climate normals // (Bekky, 2026-08-24) — the "climate scope" backend correlation. Deterministic, // no AI. Re-fetched every time the location is set/changed/reset. let season; if (loc.latitude != null && loc.longitude != null) { season = await getSeasonProfile(loc.latitude, loc.longitude); } updatePrivacy({ gardenLocation: { ...loc, capturedAt: new Date().toISOString(), ...(season ? { season } : {}) } }); setError(null); // Refresh the garden's care guidance so the new location feeds the AI // (Bekky, 2026-08-23). Fire-and-forget; the in-flight dedup guard prevents // crosstalk/races per plant. onGardenLocationSet?.(); }; const captureGps = async (mode: 'precise' | 'rough') => { setCapturing(true); setError(null); try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { setError('Location permission is off. Enable it in your device settings, or use City or ZIP instead.'); return; } const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.High }); const lat = mode === 'rough' ? Number(loc.coords.latitude.toFixed(2)) : loc.coords.latitude; const lon = mode === 'rough' ? Number(loc.coords.longitude.toFixed(2)) : loc.coords.longitude; setGardenLocation({ mode, latitude: lat, longitude: lon, label: `${lat.toFixed(4)}, ${lon.toFixed(4)}`, }); } catch { setError('Couldn’t get your location right now. Try again, or use City or ZIP instead.'); } finally { setCapturing(false); } }; const geocodeCityZipAndSet = async () => { const text = cityZipText.trim(); if (!text) return; setGeocoding(true); setError(null); try { const result = await geocodeCityZip(text); if (!result) { setError('Couldn’t find that place. Try a nearby city or ZIP code.'); return; } setGardenLocation({ mode: 'city_zip', latitude: result.latitude, longitude: result.longitude, label: result.name, }); } finally { setGeocoding(false); } }; const currentLabel = gardenLocation ? gardenLocation.label || `${gardenLocation.latitude?.toFixed(4)}, ${gardenLocation.longitude?.toFixed(4)}` : null; return ( {/* Current location status */} {currentLabel ? `Garden location: ${currentLabel}` : 'Garden location: not set yet'} {gardenLocation ? ( updatePrivacy({ gardenLocation: null }))} > Clear ) : null} {/* Care-refresh indicator (Bekky, 2026-08-23): shows while the garden's care guidance is updating after a location change, clears when done. */} {careRefreshing ? ( Updating care… ) : null} {/* Mode options */} {(Object.keys(gardenLocationLabels) as GardenLocationMode[]).map(mode => ( { if (mode === 'skip') { updatePrivacy({ gardenLocation: null }); } else if (mode === 'city_zip') { updatePrivacy({ gardenLocation: { mode: 'city_zip' } }); } else { updatePrivacy({ gardenLocation: { mode } }); } setError(null); })} > {gardenLocationLabels[mode]} {gardenLocationDescriptions[mode]} ))} {/* Capture controls for the selected mode */} {(gardenLocation?.mode === 'precise' || gardenLocation?.mode === 'rough') && ( Set it once — Pixie uses this for weather care. If you're not at your garden right now, you can update it later. captureGps(gardenLocation.mode as 'precise' | 'rough'))} disabled={capturing} > {capturing ? : Set to my current location} )} {gardenLocation?.mode === 'city_zip' && ( Type a nearby city or ZIP code. Pixie geocodes it once for regional weather. {geocoding ? : Set} )} {error ? {error} : null} The quality of your weather care and alerts reflects what you choose — more precision means richer, more local guidance. You can change this anytime. ); } // ---- Styles ---- const s = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }, scrim: { ...StyleSheet.absoluteFillObject }, panel: { flex: 1, marginHorizontal: '5%', backgroundColor: '#FFFDF7', borderRadius: 24, overflow: 'hidden' }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', padding: 16, paddingBottom: 8 }, headerCopy: { flex: 1 }, title: { color: dark, fontSize: 22, fontWeight: '900' }, subtitle: { color: '#78694B', fontSize: 13, marginTop: 2 }, closeButton: { minHeight: 36, borderRadius: 999, backgroundColor: '#F3F7E6', paddingHorizontal: 14, paddingVertical: 6, alignItems: 'center', justifyContent: 'center' }, closeText: { color: dark, fontSize: 13, fontWeight: '700' }, scrollView: { paddingHorizontal: 16 }, scrollContent: { paddingTop: 4 }, section: { marginBottom: 20 }, sectionTitle: { color: dark, fontSize: 18, fontWeight: '900', marginBottom: 4 }, sectionNote: { color: '#78694B', fontSize: 12, marginBottom: 10 }, fieldLabel: { color: dark, fontSize: 13, fontWeight: '700', marginBottom: 6, marginTop: 10 }, chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 4 }, chip: { minHeight: 36, borderRadius: 999, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', paddingHorizontal: 14, paddingVertical: 6, alignItems: 'center', justifyContent: 'center' }, chipActive: { backgroundColor: green, borderColor: green }, chipDisabled: { opacity: 0.4 }, chipText: { color: '#5B7553', fontSize: 13, fontWeight: '600' }, chipTextActive: { color: '#fff' }, chipTextDisabled: { color: '#9E9E9E' }, valueButton: { minHeight: 48, borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', paddingHorizontal: 13, paddingVertical: 9, marginBottom: 5, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, valueText: { color: dark, fontSize: 15, fontWeight: '600' }, valuePlaceholder: { color: '#9E9E9E' }, valueHint: { color: green, fontSize: 13, fontWeight: '700' }, helper: { color: '#78694B', fontSize: 12, lineHeight: 16, marginBottom: 4 }, // Green bounding box around the Garden Location section (Bekky, 2026-08-23) — // static hand-drawn green lasso, same as the environment/placement/setup tiles // (NOT a pulsing glow — that was too much). gardenLocationBox: { borderWidth: 1.6, borderColor: 'rgba(85,116,94,0.55)', borderRadius: 14, backgroundColor: 'rgba(253,247,235,0.35)', paddingHorizontal: 10, paddingVertical: 8, marginBottom: 10, }, clearButton: { minHeight: 36, borderRadius: 999, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 14, paddingVertical: 6, marginTop: 4, marginBottom: 4 }, clearText: { color: '#78694B', fontSize: 13, fontWeight: '600' }, input: { backgroundColor: '#FFFDF7', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, fontSize: 15, color: '#2E6B3F', marginBottom: 6 }, // Weather section at the top of Settings (Bekky, 2026-09-02, Chunk 2b). weatherAlertRow: { borderRadius: 12, borderWidth: 1, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', paddingHorizontal: 12, paddingVertical: 10, marginBottom: 8 }, weatherAlertTitle: { color: dark, fontSize: 14, fontWeight: '800', marginBottom: 2 }, weatherAlertSpace: { color: '#B85C4A', fontSize: 13, fontWeight: '700', marginBottom: 4 }, weatherAlertMsg: { color: '#5D5A4E', fontSize: 13, lineHeight: 18, marginTop: 6 }, weatherPlantGrid: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 6 }, weatherPlantItem: { color: '#5D5A4E', fontSize: 13, lineHeight: 20, width: '50%', paddingRight: 6 }, weatherAlertTipsButton: { marginTop: 8, borderRadius: 999, borderWidth: 1, borderColor: '#B85C4A', backgroundColor: 'rgba(255,255,255,0.6)', paddingHorizontal: 12, paddingVertical: 7, alignSelf: 'flex-start' }, weatherAlertTipsButtonText: { color: '#B85C4A', fontSize: 13, fontWeight: '700' }, weatherInfoRow: { borderRadius: 12, borderWidth: 1, borderColor: 'rgba(85,116,94,0.55)', backgroundColor: '#E8F0E4', paddingHorizontal: 12, paddingVertical: 10, marginBottom: 8 }, });