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