/** * Notification Center components for PixieSprout. * NotificationRow, NotificationSection, NotificationSnoozeSheet, NotificationCenter. */ import React, { useState } from 'react'; import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DateTimePickerAndroid, type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { green, dark, haptic, pressWithHaptic } from '../constants/theme'; // 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 { CareTaskActionState, CareTaskItem, NotificationSnoozeOption, PlantNotification, PlantNotificationState, } from '../types/appTypes'; import type { WeatherWarning, WeatherFyi } from '../services/care'; // ---- Helpers ---- export function getTaskForNotification(notification: PlantNotification, tasks: CareTaskItem[]) { return tasks.find(task => task.id === notification.relatedTaskId); } export function getNotificationScheduleLabel(value?: string | null) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' at ' + date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); } export function getNotificationSnoozeOptionLabel(option: NotificationSnoozeOption, afterWorkReminderTime: string) { return option.key === 'after_work' ? `Remind after work at ${afterWorkReminderTime}` : option.label; } export const notificationSnoozeOptions: NotificationSnoozeOption[] = [ { key: 'one_hour', label: '1 hour', actionLabel: 'Remind again in 1 hour' }, { key: 'tomorrow', label: 'Tomorrow', actionLabel: 'Snooze until tomorrow' }, { key: 'after_work', label: 'Remind after work', actionLabel: 'Remind after work' }, ]; // ---- WeatherSection (Bekky, 2026-09-02, Chunk 2b) ---- // The "harmonization home": shows weather warnings + cadence-FYI lines grouped // by severity, pinned above care tasks. Weather alerts are DISTINCT from care // notifications. Tapping an alert re-opens the alert modal (the info IS the // alert); FYI lines are non-interactive (just explain why a cadence shifted). export function WeatherSection({ warnings, fyi, onFetchTips, tipsLoadingId, }: { warnings: WeatherWarning[]; fyi: WeatherFyi[]; /** Fetch rich "how to protect" tips for a warning (Bekky, 2026-09-05). */ onFetchTips?: (w: WeatherWarning) => void; /** Warning id currently fetching tips (shows "Pixie's thinking…"). */ tipsLoadingId?: string | null; }) { const alerts = warnings.filter(w => w.severity === 'alert'); const warns = warnings.filter(w => w.severity === 'warning'); const info = warnings.filter(w => w.severity === 'info'); const hasAny = alerts.length + warns.length + info.length + fyi.length > 0; if (!hasAny) return null; return ( Weather {/* ALERT — the urgent one, pink box (Bekky: "so everyone knows this is the urgent one"). Non-interactive: the full message + advice is shown inline (Bekky, 2026-09-05 — the tap just parroted the alert, so the link is gone and everything lives in the card). When the warning is space-scoped with a plant list, render the plants as a tidy 2-column list (Bekky, 2026-09-05). */} {alerts.map(w => ( {w.title} {w.spaceName ? {w.spaceName} : null} {w.plants && w.plants.length > 0 ? ( {w.plants.map((name, i) => ( • {name} ))} ) : null} {w.advice || w.message} {onFetchTips ? ( onFetchTips(w))} disabled={tipsLoadingId === w.id} accessibilityRole="button" accessibilityLabel={w.tips ? 'See tips on protecting these plants' : 'Get tips on protecting these plants'} > {tipsLoadingId === w.id ? 'Pixie’s thinking… 🌱' : (w.tips ? 'See tips 🌱' : 'Want tips on protecting them? 🌱')} ) : null} ))} {/* WARNING — amber rows (heat wave, heavier rain). Non-interactive FYI. */} {warns.map(w => ( {w.title} {w.message} ))} {/* INFO warnings (from evaluateWeatherWarnings) */} {info.map(w => ( {w.title} {w.message} ))} {/* CADENCE FYI — brief, non-interactive daily lines (regular weather) */} {fyi.map(f => ( {f.message} ))} ); } // ---- NotificationRow ---- export function NotificationRow({ notification, notificationState, urgent, onOpenRelated, onRemind, onDismiss, }: { notification: PlantNotification; notificationState?: PlantNotificationState; urgent?: boolean; onOpenRelated: () => void; onRemind?: () => void; onDismiss: () => void; }) { const snoozedLabel = notificationState?.snoozedUntil ? getNotificationScheduleLabel(notificationState.snoozedUntil) : ''; return ( {!notification.isRead ? : null} {notification.category || 'Reminder'} {notification.title} {onRemind && notification.type === 'care_reminder' ? ( Remind me ) : null} Dismiss {notification.message} {notification.reason ? {notification.reason} : null} {snoozedLabel ? Snoozed until {snoozedLabel} : null} {notification.type === 'care_reminder' ? ( Tap to open in Care ) : null} ); } // ---- NotificationSection ---- export function NotificationSection({ title, notifications, tasks, actionStates, notificationStates, showCareActions, urgentFirst, emptyText, onMarkRead, onComplete, onSnooze, onRemindLater, onOpenRelated, onDismiss, }: { title: string; notifications: PlantNotification[]; tasks: CareTaskItem[]; actionStates: Record; notificationStates: Record; showCareActions: boolean; urgentFirst?: boolean; emptyText: string; onMarkRead: (notification: PlantNotification) => void; onComplete: (task: CareTaskItem, notification: PlantNotification) => void; onSnooze: (task: CareTaskItem, notification: PlantNotification) => void; onRemindLater: (notification: PlantNotification) => void; onOpenRelated: (notification: PlantNotification, task?: CareTaskItem) => void; onDismiss: (notification: PlantNotification) => void; }) { return ( {title} {notifications.length ? notifications.map((notification, index) => { const task = getTaskForNotification(notification, tasks); // No "Remind me" on a completed task (Bekky, 2026-08-18) — it's done, // reminding makes no sense. Keep Dismiss. const taskState = task ? actionStates[task.id] : undefined; const isCompleted = taskState?.status === 'completed'; return ( onOpenRelated(notification, task)} onRemind={task && !isCompleted ? () => onSnooze(task, notification) : undefined} onDismiss={() => onDismiss(notification)} /> ); }) : {emptyText}} ); } // ---- NotificationSnoozeSheet ---- export function NotificationSnoozeSheet({ task, visible, afterWorkReminderTime, onSelect, onClose, }: { task: CareTaskItem | null; visible: boolean; afterWorkReminderTime: string; onSelect: (option: NotificationSnoozeOption) => void; onClose: () => void; }) { const [supplyDays, setSupplyDays] = useState(3); const supplyDayOptions = [2, 3, 5, 7]; // Pixel-based max height (L5) so the sheet scrolls instead of overflowing on // small screens. leave room for the bottom safe-area + scrim tap target. const insets = useSafeAreaInsets(); const sheetMaxHeight = Math.max(320, (useWindowDimensions().height ?? 0) - (insets.top + 24) - (insets.bottom + 16)); const openCustomPicker = () => { const selected = new Date(); // Android supports 'date' and 'time' modes separately — open date first, then time. DateTimePickerAndroid.open({ value: selected, mode: 'date', display: 'default', minimumDate: new Date(), onChange: (event: DateTimePickerEvent, selectedDate?: Date) => { if (event.type !== 'set' || !selectedDate) return; selected.setFullYear(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate()); DateTimePickerAndroid.open({ value: selected, mode: 'time', display: 'default', is24Hour: false, onChange: (event2: DateTimePickerEvent, timeDate?: Date) => { if (event2.type !== 'set' || !timeDate) return; selected.setHours(timeDate.getHours(), timeDate.getMinutes(), 0, 0); onSelect({ key: 'custom', label: 'Custom', actionLabel: `Remind at ${selected.toLocaleDateString()} ${selected.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`, customDate: selected }); }, }); }, }); }; return ( Remind me later Pick a gentle time for this reminder to return. {notificationSnoozeOptions.map(option => ( onSelect(option), haptic.light)} accessibilityRole="button" accessibilityLabel={`Remind me later: ${getNotificationSnoozeOptionLabel(option, afterWorkReminderTime)}`} > {getNotificationSnoozeOptionLabel(option, afterWorkReminderTime)} ))} Custom date & time {/* Need supplies first — pick how many days to give yourself to get materials */} Need supplies first? Snooze until you've got what the treatment needs. {supplyDayOptions.map(d => { const active = supplyDays === d; return ( setSupplyDays(d), haptic.light)} > {d} days ); })} onSelect({ key: 'need_supplies', label: 'Need supplies first', actionLabel: 'Remind me when I have supplies', days: supplyDays }), haptic.success)} > 🛒 Snooze {supplyDays} days for supplies Cancel ); } // ---- NotificationCenter ---- export function NotificationCenter({ visible, notifications, tasks, actionStates, notificationStates, afterWorkReminderTime, weatherWarnings = [], cadenceFyi = [], onFetchTips, tipsLoadingId, onClose, onMarkRead, onMarkAllRead, onComplete, onSnooze, onRemindLater, onOpenRelated, onDismiss, }: { visible: boolean; notifications: PlantNotification[]; tasks: CareTaskItem[]; actionStates: Record; notificationStates: Record; afterWorkReminderTime: string; /** Weather alerts/warnings (Bekky, 2026-09-02, Chunk 2b): distinct from care * notifications — rendered in the pinned Weather section at the top. */ weatherWarnings?: WeatherWarning[]; /** Cadence FYI lines (regular weather, no AI) — the harmonization home. */ cadenceFyi?: WeatherFyi[]; /** Fetch rich "how to protect" tips for a warning (Bekky, 2026-09-05). */ onFetchTips?: (w: WeatherWarning) => void; /** Warning id currently fetching tips (shows "Pixie's thinking…"). */ tipsLoadingId?: string | null; onClose: () => void; onMarkRead: (notification: PlantNotification) => void; onMarkAllRead: () => void; onComplete: (task: CareTaskItem, notification: PlantNotification) => void; onSnooze: (task: CareTaskItem, notification: PlantNotification) => void; onRemindLater: (notification: PlantNotification) => void; onOpenRelated: (notification: PlantNotification, task?: CareTaskItem) => void; onDismiss: (notification: PlantNotification) => void; }) { // Pixel-based panel inset (L4 — was percentage top/bottom, which can overlap // the status/nav bars on some devices). Use safe-area insets instead. const insets = useSafeAreaInsets(); const taskById = new Map(tasks.map(task => [task.id, task])); const activeNotifications = notifications.filter(notification => !notification.isDismissed); const isTaskActionable = (notification: PlantNotification) => { const task = notification.relatedTaskId ? taskById.get(notification.relatedTaskId) : undefined; const state = task ? actionStates[task.id] : undefined; return Boolean(task && state?.status !== 'completed' && state?.status !== 'skipped' && state?.status !== 'snoozed'); }; const overdue = activeNotifications.filter(notification => { const task = notification.relatedTaskId ? taskById.get(notification.relatedTaskId) : undefined; return task?.isOverdue && isTaskActionable(notification) && !notificationStates[notification.id]?.snoozedUntil; }); const dueToday = activeNotifications.filter(notification => { const task = notification.relatedTaskId ? taskById.get(notification.relatedTaskId) : undefined; return task?.timing === 'today' && !task.isOverdue && isTaskActionable(notification) && !notificationStates[notification.id]?.snoozedUntil; }); const snoozed = activeNotifications.filter(notification => { const task = notification.relatedTaskId ? taskById.get(notification.relatedTaskId) : undefined; const state = task ? actionStates[task.id] : undefined; return Boolean(task && state?.status !== 'completed' && state?.status !== 'skipped' && (state?.status === 'snoozed' || notificationStates[notification.id]?.snoozedUntil)); }).sort((a, b) => { // Soonest snooze trigger first. const aAt = notificationStates[a.id]?.snoozedUntil || ''; const bAt = notificationStates[b.id]?.snoozedUntil || ''; if (aAt && bAt) return aAt.localeCompare(bAt); if (aAt) return -1; if (bAt) return 1; return 0; }); // Recent Activity = completed tasks, but only for 24 hours (Bekky, 2026-08-18). // Completed notifications auto-wipe after a day — no need to keep them forever. // Uses the persisted completedAt, so the wipe is deterministic across open/close. const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; const nowMs = Date.now(); const recentActivity = notifications.filter(notification => { const task = notification.relatedTaskId ? taskById.get(notification.relatedTaskId) : undefined; if (!task || actionStates[task.id]?.status !== 'completed') return false; const completedAt = actionStates[task.id]?.completedAt; if (!completedAt) return false; return nowMs - new Date(completedAt).getTime() < TWENTY_FOUR_HOURS_MS; }); return ( Notifications Close {/* Weather first, pinned above care tasks (Bekky, 2026-09-02) */} Mark all read ); } // ---- Styles ---- const s = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }, scrim: { ...StyleSheet.absoluteFillObject }, panel: { position: 'absolute', top: '10%', left: '5%', right: '5%', bottom: '10%', backgroundColor: '#FFFDF7', borderRadius: 24, padding: 16 }, panelHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }, panelTitleWrap: { flex: 1 }, panelTitle: { color: dark, fontSize: 22, fontWeight: '900' }, panelSubtitle: { 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' }, preferenceNote: { backgroundColor: '#F3F7E6', borderRadius: 10, padding: 8, marginBottom: 10 }, preferenceText: { color: '#5B7553', fontSize: 12, fontWeight: '600' }, markReadButton: { minHeight: 44, borderRadius: 14, backgroundColor: '#F3F7E6', alignItems: 'center', justifyContent: 'center', marginTop: 8 }, markReadText: { color: dark, fontSize: 14, fontWeight: '700' }, // Weather section (Bekky, 2026-09-02, Chunk 2b) — pinned above care tasks. weatherSection: { marginBottom: 16 }, weatherRow: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 10, marginBottom: 8 }, weatherRowAlert: { backgroundColor: '#FCE9E6', borderColor: '#B85C4A' }, weatherRowWarn: { backgroundColor: '#FFF3E0', borderColor: '#E0A33A' }, weatherRowInfo: { backgroundColor: '#E8F0E4', borderColor: 'rgba(85,116,94,0.55)' }, weatherRowTitle: { color: dark, fontSize: 14, fontWeight: '800', marginBottom: 2 }, weatherRowSpace: { color: '#B85C4A', fontSize: 13, fontWeight: '700', marginBottom: 4 }, weatherRowMsg: { 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 }, weatherTipsButton: { marginTop: 8, borderRadius: 999, borderWidth: 1, borderColor: '#B85C4A', backgroundColor: 'rgba(255,255,255,0.6)', paddingHorizontal: 12, paddingVertical: 7, alignSelf: 'flex-start' }, weatherTipsButtonText: { color: '#B85C4A', fontSize: 13, fontWeight: '700' }, section: { marginBottom: 16 }, sectionTitle: { color: dark, fontSize: 16, fontWeight: '900', marginBottom: 8 }, empty: { color: '#9E9E9E', fontSize: 13, fontStyle: 'italic', padding: 12 }, row: { backgroundColor: 'rgba(255,253,247,0.96)', borderRadius: 16, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', padding: 12, marginBottom: 8 }, rowUnread: { borderColor: green }, rowUrgent: { backgroundColor: green, borderColor: green }, rowHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }, titleWrap: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 }, unreadDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: green }, unreadDotUrgent: { backgroundColor: '#fff' }, titleColumn: { flex: 1 }, categoryText: { color: '#9E9E9E', fontSize: 10, fontWeight: '600', textTransform: 'uppercase' }, categoryTextUrgent: { color: 'rgba(255,255,255,0.75)' }, title: { color: dark, fontSize: 14, fontWeight: '800' }, titleUrgent: { color: '#fff' }, headerActions: { flexDirection: 'row', gap: 6 }, dismissButton: { minHeight: 40, borderRadius: 999, backgroundColor: '#F3F7E6', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', paddingHorizontal: 14, paddingVertical: 6, alignItems: 'center', justifyContent: 'center' }, dismissText: { color: '#5B7553', fontSize: 12, fontWeight: '700' }, message: { color: '#5B7553', fontSize: 13, lineHeight: 18 }, messageUrgent: { color: 'rgba(255,255,255,0.95)' }, reason: { color: '#9E9E9E', fontSize: 11, marginTop: 4 }, reasonUrgent: { color: 'rgba(255,255,255,0.75)' }, meta: { color: '#9E9E9E', fontSize: 11, marginTop: 4, fontStyle: 'italic' }, metaUrgent: { color: 'rgba(255,255,255,0.75)' }, tapToCare: { color: green, fontSize: 12, fontWeight: '700', marginTop: 6, textAlign: 'center' }, tapToCareUrgent: { color: '#fff' }, actionRow: { flexDirection: 'row', gap: 8, marginTop: 10 }, actionButton: { minHeight: 36, flex: 1, borderRadius: 14, backgroundColor: green, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, actionText: { color: '#fff', fontSize: 12, fontWeight: '800', textAlign: 'center' }, actionButtonQuiet: { minHeight: 36, flex: 1, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 10 }, actionTextQuiet: { color: dark, fontSize: 12, fontWeight: '700', textAlign: 'center' }, sheetOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }, sheet: { backgroundColor: '#FFFDF7', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 20, paddingBottom: 40 }, sheetTitle: { color: dark, fontSize: 20, fontWeight: '900', marginBottom: 4 }, sheetSubtitle: { color: '#78694B', fontSize: 13, marginBottom: 16 }, sheetOption: { minHeight: 48, borderRadius: 14, backgroundColor: '#F3F7E6', alignItems: 'center', justifyContent: 'center', marginBottom: 8 }, sheetOptionText: { color: dark, fontSize: 15, fontWeight: '700' }, sheetCancel: { minHeight: 48, borderRadius: 14, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', marginTop: 4 }, sheetCancelText: { color: '#8E2F1E', fontSize: 15, fontWeight: '700' }, sheetSuppliesBlock: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: 'rgba(191,217,180,0.6)' }, sheetSuppliesTitle: { color: dark, fontSize: 15, fontWeight: '900' }, sheetSuppliesSub: { color: '#78694B', fontSize: 12, marginTop: 2, marginBottom: 10 }, supplyDayRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 10 }, supplyDayChip: { minHeight: 40, borderRadius: 999, backgroundColor: '#F3F7E6', borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 }, supplyDayChipActive: { backgroundColor: green, borderColor: green }, supplyDayChipText: { color: dark, fontSize: 14, fontWeight: '700' }, supplyDayChipTextActive: { color: '#fff', fontWeight: '900' }, sheetSuppliesButton: { minHeight: 48, borderRadius: 14, backgroundColor: green, alignItems: 'center', justifyContent: 'center' }, sheetSuppliesButtonText: { color: '#fff', fontSize: 15, fontWeight: '800' }, });