/** * CadenceWheelModal — a JS-only number wheel for setting a per-plant care * cadence (how many days between reminders). Built with a ScrollView + snap * so it needs NO native module → OTA-shippable. Spin to select 1-90 days. */ import React, { useEffect, useRef, useState } from 'react'; import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import { green, dark, line } from '../constants/theme'; const EdgeModal = Modal as any; // Water = blue-toned, fertilize = green (Bekky, 2026-08-21). The wheel's // center highlight + selected number use the action's accent color. const WATER_ACCENT = '#2E7DB8'; // water blue const FERT_ACCENT = green; // fertilize green const ITEM_HEIGHT = 48; const VISIBLE = 5; // odd number so there's a clear center row const WHEEL_HEIGHT = ITEM_HEIGHT * VISIBLE; // Compact date picker (Bekky, 2026-08-21): the date wheel was too tall at the // number-wheel size. Use a shorter item + fewer visible rows. const DATE_ITEM_HEIGHT = 36; const DATE_VISIBLE = 3; const DATE_WHEEL_HEIGHT = DATE_ITEM_HEIGHT * DATE_VISIBLE; const MIN_DAYS = 1; const MAX_DAYS = 90; // ---- Date spinner bounds ---- const MIN_YEAR = 2020; const MAX_YEAR = 2100; const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; interface Props { visible: boolean; title: string; message?: string; initialDays: number; /** Current "last done" anchor for this action (local YYYY-MM-DD) or null. */ initialLastDone?: string | null; /** Accent color for the wheel highlight — water=blue, fertilize=green. */ accent?: string; /** Date-only mode (Bekky, 2026-08-21, Phase 2): hide the number wheel — the * manual cadence override is removed, so this modal only sets the "last * done" date. */ dateOnly?: boolean; onSave: (days: number, lastDone: string | null) => void; /** Secondary button label. QPASS (Bekky 2026-08-30): group mode passes 'Cancel' — * "Use Pixie's default" is meaningless for a group last-watered date. Empty string * hides the secondary button entirely. Default preserves legacy plant-mode label. */ secondaryLabel?: string; /** QPASS (Bekky 2026-08-30): optional honest status line under the title, e.g. * "On file: Jul 12 – Aug 2 (varies by plant)" — the modal never showed what was * on file before. Omitted = renders nothing (legacy behavior). */ onFileLine?: string; onUseDefault: () => void; onClose: () => void; } /** Days in a month for a given local year/month (handles leap years). */ function daysInMonth(year: number, month: number): number { return new Date(year, month, 0).getDate(); } /** Build the parallel arrays (year/month/day) from an initial date key (or today). */ function dateSpinnerParts(dateKey?: string | null): { year: number; month: number; day: number } { if (dateKey && /^\d{4}-\d{2}-\d{2}$/.test(dateKey)) { const [y, m, d] = dateKey.split('-').map(Number); if (y >= MIN_YEAR && y <= MAX_YEAR && m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)) { return { year: y, month: m, day: d }; } } // Default to today. const today = new Date(); return { year: today.getFullYear(), month: today.getMonth() + 1, day: today.getDate() }; } function toDateKey(year: number, month: number, day: number): string { const mm = String(month).padStart(2, '0'); const dd = String(day).padStart(2, '0'); return `${year}-${mm}-${dd}`; } export function CadenceWheelModal({ visible, title, message, initialDays, initialLastDone, accent = green, dateOnly = false, secondaryLabel, onFileLine, onSave, onUseDefault, onClose }: Props) { const scrollRef = useRef(null); const yearScrollRef = useRef(null); const monthScrollRef = useRef(null); const dayScrollRef = useRef(null); const [days, setDays] = useState(initialDays); const [ready, setReady] = useState(false); const initialParts = dateSpinnerParts(initialLastDone); const [year, setYear] = useState(initialParts.year); const [month, setMonth] = useState(initialParts.month); const [day, setDay] = useState(initialParts.day); const [showDatePicker, setShowDatePicker] = useState(false); const [dateSelected, setDateSelected] = useState(!!initialLastDone); // Clamp the day when month/year changes to a shorter month. useEffect(() => { setDay(d => Math.min(d, daysInMonth(year, month))); }, [year, month]); // Reset the wheel + date each time it opens. useEffect(() => { if (visible) { setDays(initialDays); const p = dateSpinnerParts(initialLastDone); setYear(p.year); setMonth(p.month); setDay(p.day); setDateSelected(!!initialLastDone); setShowDatePicker(false); setReady(false); // Wait a frame so the ScrollView is laid out before jumping. requestAnimationFrame(() => { setReady(true); scrollRef.current?.scrollTo({ y: (initialDays - MIN_DAYS) * ITEM_HEIGHT, animated: false }); }); } }, [visible, initialDays, initialLastDone]); // When the date picker opens, position each wheel on its current value. // QPASS FIX (Bekky screenshot 2026-08-30): in dateOnly mode this rAF fired BEFORE the // date ScrollViews laid out, so the wheel parked at list-top (2020/Jan/1 = MIN_YEAR) // instead of today. Fix: longer settle (timeout after rAF) + repeat-once retry. useEffect(() => { if (showDatePicker || dateOnly) { const jump = () => { yearScrollRef.current?.scrollTo({ y: (year - MIN_YEAR) * DATE_ITEM_HEIGHT, animated: false }); monthScrollRef.current?.scrollTo({ y: (month - 1) * DATE_ITEM_HEIGHT, animated: false }); dayScrollRef.current?.scrollTo({ y: (day - 1) * DATE_ITEM_HEIGHT, animated: false }); }; requestAnimationFrame(() => { jump(); // Second pass one tick later — first layout passes can reset contentOffset. setTimeout(jump, 32); }); } }, [showDatePicker, dateOnly, initialLastDone, visible]); const onMomentumEnd = (e: NativeSyntheticEvent) => { const idx = Math.round(e.nativeEvent.contentOffset.y / ITEM_HEIGHT); const clamped = Math.max(MIN_DAYS, Math.min(MAX_DAYS, MIN_DAYS + idx)); setDays(clamped); }; const yearOptions = []; for (let y = MIN_YEAR; y <= MAX_YEAR; y++) yearOptions.push(y); const monthOptions = []; for (let m = 1; m <= 12; m++) monthOptions.push(m); const dayOptions = []; for (let d = 1; d <= daysInMonth(year, month); d++) dayOptions.push(d); const items = []; for (let d = MIN_DAYS; d <= MAX_DAYS; d++) { const isCenter = d === days; items.push( {d} ); } const onYearScroll = (e: NativeSyntheticEvent) => { const idx = Math.round(e.nativeEvent.contentOffset.y / DATE_ITEM_HEIGHT); setYear(Math.max(MIN_YEAR, Math.min(MAX_YEAR, MIN_YEAR + idx))); }; const onMonthScroll = (e: NativeSyntheticEvent) => { const idx = Math.round(e.nativeEvent.contentOffset.y / DATE_ITEM_HEIGHT); setMonth(Math.max(1, Math.min(12, 1 + idx))); }; const onDayScroll = (e: NativeSyntheticEvent) => { const idx = Math.round(e.nativeEvent.contentOffset.y / DATE_ITEM_HEIGHT); setDay(Math.max(1, Math.min(daysInMonth(year, month), 1 + idx))); }; // A generic small wheel (used for the 3 date columns). const renderDateWheel = ( ref: React.RefObject, options: number[], value: number, label: (v: number) => string, onScroll: (e: NativeSyntheticEvent) => void, onMomentum: (e: NativeSyntheticEvent) => void ) => ( {label(value)} {options.map((v) => ( {label(v)} ))} ); return ( {title} {onFileLine ? {onFileLine} : null} {message ? {message} : null} {!dateOnly ? ( <> {items} {days} {days === 1 ? 'day' : 'days'} ) : null} When did you last do this? Choose approximate date to adjust schedule. {/* Toggle to reveal the date picker (always shown in dateOnly mode). */} {dateOnly ? ( {renderDateWheel(yearScrollRef, yearOptions, year, (v) => String(v), onYearScroll, (e) => { onYearScroll(e); })} {renderDateWheel(monthScrollRef, monthOptions, month, (v) => MONTH_LABELS[v - 1], onMonthScroll, (e) => { onMonthScroll(e); })} {renderDateWheel(dayScrollRef, dayOptions, day, (v) => String(v), onDayScroll, (e) => { onDayScroll(e); })} ) : ( { setShowDatePicker(v => !v); }}> {showDatePicker ? '✕ Hide date' : '📅 Choose date'} )} {showDatePicker && !dateOnly ? ( {renderDateWheel(yearScrollRef, yearOptions, year, (v) => String(v), onYearScroll, (e) => { onYearScroll(e); })} {renderDateWheel(monthScrollRef, monthOptions, month, (v) => MONTH_LABELS[v - 1], onMonthScroll, (e) => { onMonthScroll(e); })} {renderDateWheel(dayScrollRef, dayOptions, day, (v) => String(v), onDayScroll, (e) => { onDayScroll(e); })} ) : null} {dateSelected ? ( You selected: {MONTH_LABELS[month - 1]} {day}, {year} ) : null} onSave(days, dateSelected || showDatePicker ? toDateKey(year, month, day) : null)}> Save {secondaryLabel !== '' ? ( {secondaryLabel || "Use Pixie's default"} ) : null} ); } const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.55)', justifyContent: 'center', alignItems: 'center', padding: 28, }, card: { width: '100%', maxWidth: 360, backgroundColor: '#FFFCF5', borderRadius: 24, padding: 24, shadowColor: '#000', shadowOpacity: 0.25, shadowRadius: 20, shadowOffset: { width: 0, height: 8 }, elevation: 12, }, title: { fontSize: 20, fontWeight: '800', color: dark, textAlign: 'center', marginBottom: 8, }, message: { fontSize: 15, color: '#3A382E', textAlign: 'center', lineHeight: 22, marginBottom: 16, }, onFileLine: { fontSize: 13, color: '#6E7B5E', textAlign: 'center', marginBottom: 10, }, wheelWrap: { height: WHEEL_HEIGHT, overflow: 'hidden', borderRadius: 16, borderWidth: 1, borderColor: line, backgroundColor: '#F7F1E4', marginBottom: 8, }, wheelCenterLine: { position: 'absolute', top: (WHEEL_HEIGHT - ITEM_HEIGHT) / 2, left: 0, right: 0, height: ITEM_HEIGHT, backgroundColor: 'rgba(46, 107, 63, 0.10)', borderRadius: 8, zIndex: 1, }, wheel: { flex: 1, }, item: { height: ITEM_HEIGHT, alignItems: 'center', justifyContent: 'center', }, itemCenter: { // highlighted by the center line behind it }, itemText: { fontSize: 20, fontWeight: '600', color: '#8A8571', }, itemTextCenter: { fontSize: 24, fontWeight: '900', color: green, }, daysLabel: { textAlign: 'center', fontSize: 15, fontWeight: '700', color: '#3A382E', marginBottom: 16, }, lastDoneLabel: { fontSize: 14, fontWeight: '800', color: dark, textAlign: 'center', marginBottom: 2, }, lastDoneHint: { fontSize: 13, color: '#8A8571', textAlign: 'center', lineHeight: 19, marginBottom: 8, }, dateToggleRow: { alignSelf: 'center', paddingHorizontal: 16, paddingVertical: 9, borderRadius: 999, borderWidth: 1.5, borderColor: green, backgroundColor: '#EAF5E2', marginBottom: 14, }, dateToggleLabel: { fontSize: 14, fontWeight: '800', color: green, }, datePickerWrap: { flexDirection: 'row', justifyContent: 'space-between', gap: 8, marginBottom: 14, }, dateCol: { flex: 1, }, dateColLabel: { textAlign: 'center', fontSize: 13, fontWeight: '800', color: '#8A8571', marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.4, }, dateWheel: { height: DATE_WHEEL_HEIGHT, borderRadius: 14, borderWidth: 1, borderColor: line, backgroundColor: '#F7F1E4', overflow: 'hidden', }, dateItem: { height: DATE_ITEM_HEIGHT, alignItems: 'center', justifyContent: 'center', }, dateItemText: { fontSize: 16, fontWeight: '600', color: '#8A8571', }, dateItemTextCenter: { fontSize: 18, fontWeight: '900', color: green, }, datePreview: { textAlign: 'center', fontSize: 14, fontWeight: '700', color: '#3A382E', marginBottom: 14, }, saveBtn: { minHeight: 52, borderRadius: 999, backgroundColor: dark, alignItems: 'center', justifyContent: 'center', marginBottom: 10, }, saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '900', }, defaultBtn: { minHeight: 50, borderRadius: 999, borderWidth: 1.5, borderColor: line, backgroundColor: '#FBF6E9', alignItems: 'center', justifyContent: 'center', }, defaultBtnText: { color: dark, fontSize: 16, fontWeight: '900', }, });