/** * Care task UI components for PixieSprout. * CareTaskIcon, CareTaskCard, CareUpcomingGroup, CareCompletedRow, CareFeedback, * CareTaskDetailSections, getCareTaskDetailStatus. */ import React, { useState } from 'react'; import { Image, Modal, ScrollView, StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle, } from 'react-native'; import { green, dark, line, haptic, pressWithHaptic } from '../constants/theme'; import type { CareTaskActionState, CareTaskItem } from '../types/appTypes'; import type { CareFeedbackState } from '../types/appTypes'; import { CohortSoilCheckModal } from './CohortSoilCheckModal'; /** * Can a care task be completed EARLY (ahead of schedule)? * Rule (Bekky, 2026-08-20; fertilize widened 2026-09-01): WATER is early-completable * only if due within 3 days or less — an overdue task, due-today, or due in the * next 3 days. FERTILIZE gets a WEEK early window (due within 7 days) because * feeding cadences run 2-4 weeks and a ≤3-day window would make a feeding group * almost never actionable (Bekky, 2026-09-01: "you can give fertilize a week early * option for complete"). A task due next week/month is NOT early-completable. * `dueDate` is a local YYYY-MM-DD key (or null when unknown); a task with no * known due date is treated as not early-completable. */ export function canCompleteCareTaskEarly(task: CareTaskItem): boolean { const key = task.dueDate; if (!key || !/^\d{4}-\d{2}-\d{2}$/.test(key)) return false; const now = new Date(); const today = new Date( `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T00:00:00` ); const due = new Date(`${key}T00:00:00`); if (Number.isNaN(due.getTime())) return false; const daysUntil = Math.round((due.getTime() - today.getTime()) / 86400000); // Fertilize gets a week early window; water (and everything else) keeps ≤3 days. const isFertilize = task.type === 'Fertilize' || task.type === 'fertilize'; const windowDays = isFertilize ? 7 : 3; return daysUntil <= windowDays; } /** Whole days from today until a task's due date (0 = today, negative = overdue). * Returns null when the due date is unknown/malformed. (Bekky, 2026-09-02) */ export function daysUntilDue(task: CareTaskItem): number | null { const key = task.dueDate; if (!key || !/^\d{4}-\d{2}-\d{2}$/.test(key)) return null; const now = new Date(); const today = new Date( `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T00:00:00` ); const due = new Date(`${key}T00:00:00`); if (Number.isNaN(due.getTime())) return null; return Math.round((due.getTime() - today.getTime()) / 86400000); } /** * Display-time safety net for AI-written care prose (Prong 3, Bekky 2026-09-01). * Defense-in-depth: even if a prompt rule slips and the AI emits bare shorthand, * never leak "4d", "3d ago", or "→" to the reader. Converts them to friendly * plain English BEFORE render. Idempotent and cheap. */ export function humanizeCareProse(text?: string | null): string { if (!text) return ''; return text // arrows -> plain connectors .replace(/→/g, ' \u00b7 ') .replace(/\s*->\s*/g, ' then ') // "Nd ago" -> "N days ago" .replace(/\b(\d+)\s*d\s+ago\b/gi, '$1 days ago') // bare "Nd" (not followed by 'ays') -> "N days" .replace(/\b(\d+)\s*d\b(?!ays)/gi, '$1 days') .replace(/\s{2,}/g, ' ') .trim(); } // ---- getCareTaskDetailStatus ---- export function getCareTaskDetailStatus(task: CareTaskItem, actionState?: CareTaskActionState) { if (actionState?.status === 'completed') { return actionState.completedAt ? `Completed ${new Date(actionState.completedAt).toLocaleDateString()}` : 'Completed today'; } if (actionState?.status === 'snoozed') { return task.actionLabel || `Due ${task.dueLabel}`; } if (actionState?.status === 'skipped') { return actionState.skippedAt ? `Skipped ${new Date(actionState.skippedAt).toLocaleDateString()}` : 'Skipped today'; } return task.actionLabel || `Due ${task.dueLabel}`; } // ---- CareTaskIcon ---- export function CareTaskIcon({ task }: { task: CareTaskItem }) { return ( ); } // ---- CareTaskCard ---- export function CareTaskCard({ task, expanded, actionState, allowActions, onToggle, onComplete, onSkip, onCheckIn, onRemind, onViewCase, }: { task: CareTaskItem; expanded: boolean; actionState?: CareTaskActionState; allowActions: boolean; onToggle: () => void; onComplete?: () => void; onSkip?: () => void; onCheckIn?: () => void; onRemind?: () => void; onViewCase?: () => void; }) { // Power to the user (Bekky, 2026-08-18): allow completing/skipping a care // task AHEAD of time — no longer gated to 'today'. If it's due tomorrow or // in 3 days and you did it today, you can check it off now. const canShowActions = allowActions && actionState?.status !== 'completed' && actionState?.status !== 'skipped'; const statusLabel = getCareTaskDetailStatus(task, actionState); const isRecheck = task.type === 'Recheck' && task.source === 'diagnosis'; return ( {task.type} {task.plantName} {task.spaceName || 'No space assigned'} {task.sourceText ? {task.sourceText} : null} {task.weatherNote ? {task.weatherNote} : null} {task.dueLabel} {expanded ? ( {statusLabel} {canShowActions ? ( isRecheck ? ( 📸 Check In Cancel ) : ( Complete Skip {onViewCase ? ( {task.source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} ) ) : ( You can complete this task whenever you're ready — even ahead of schedule. )} ) : null} ); } // ---- CareUpcomingGroup ---- export function CareUpcomingGroup({ title, tasks, expandedTaskId, actionStates, onToggleTask, onViewCase, }: { title: string; tasks: CareTaskItem[]; expandedTaskId: string | null; actionStates: Record; onToggleTask: (task: CareTaskItem) => void; onViewCase?: (task: CareTaskItem) => void; }) { return ( {title} {tasks.length ? tasks.map(task => ( onToggleTask(task)} onViewCase={onViewCase ? () => onViewCase(task) : undefined} /> )) : Nothing planned here yet.} ); } // ---- CareCompletedRow ---- export function CareCompletedRow({ task, completedAt, expanded, actionState, onToggle, }: { task: CareTaskItem; completedAt?: string; expanded: boolean; actionState?: CareTaskActionState; onToggle: () => void; }) { return ( {task.type} {task.plantName} {task.spaceName || 'No space assigned'} - {completedAt ? 'Just now' : 'Today'} {expanded ? 'Hide' : 'Details'} {expanded ? ( {getCareTaskDetailStatus(task, actionState)} ) : null} ); } // ---- CareFeedback ---- export function CareFeedback({ feedback }: { feedback: CareFeedbackState }) { return ( {feedback.message} {feedback.undo ? ( Undo ) : null} ); } // ---- CareTaskDetailSections ---- export function CareTaskDetailSections({ task }: { task: CareTaskItem }) { const action = task.sourceText || task.actionLabel || task.type; const due = task.dueLabel || 'when it\'s due'; const fromPlan = task.source === 'diagnosis' && task.metadata?.relatedDiagnosisId; const brief = fromPlan ? `${action}, due ${due.toLowerCase()}. This is part of ${task.plantName}'s treatment plan.` : `${action}, due ${due.toLowerCase()}. Tap Complete once you've done it.`; return ( {brief} ); } /** * The single-sentence description shown on a compact journal task row. * The AI already returns a clean action (e.g. "Clean leaves and apply DIY * neem spray") as `sourceText` — we show exactly that, WITHOUT the redundant * "due today. This is part of X's treatment plan" tail (it's implied by the * row's own title + due label). The full info-rich AI return is untouched. */ export function getJournalTaskBrief(task: CareTaskItem): string { return task.sourceText || task.actionLabel || task.type; } // ---- Journal entry rows (the beautiful gardener's journal look) ---- // Tasks are handcrafted journal entries sitting on the paper's ruled lines — // a hand-drawn checkbox + the text. No icon boxes, no white rounded cards. /** Hand-drawn square checkbox. `checked` shows a clean pencil-green check (✓). */ export function JournalCheckbox({ checked = false, size = 24 }: { checked?: boolean; size?: number }) { return ( {checked ? ( ) : null} ); } /** A journal section heading ("Due Today", "Upcoming") in storybook serif. */ export function JournalSectionTitle({ title }: { title: string }) { return ( {title} ); } /** A sub-group heading inside Upcoming ("Tomorrow", "This Week", "Later"). */ export function JournalSubTitle({ title }: { title: string }) { return {title}; } /** * A due-today task as a compact handwritten journal entry. * NO expandable drop-down — the row is always fully visible: checkbox (tap to * complete → pencil check), title, space, a single-sentence brief, and the * small Skip / View case actions. Keeping rows compact lets many fit on one page. */ export function JournalTaskRow({ task, actionState, allowActions, onComplete, onSkip, onCheckIn, onViewCase, hideBrief = false, }: { task: CareTaskItem; actionState?: CareTaskActionState; allowActions: boolean; onComplete?: () => void; onSkip?: () => void; onCheckIn?: () => void; onViewCase?: () => void; /** Hide the 2nd-line sentence (Bekky, 2026-08-20): in grouped rows the brief duplicates the title ("Water Mexican Heather" twice), so drop it there. */ hideBrief?: boolean; }) { const done = actionState?.status === 'completed'; const skipped = actionState?.status === 'skipped'; // Power to the user (Bekky, 2026-08-18): allow completing ahead of time. const canShowActions = allowActions && !done && !skipped; const isRecheck = task.type === 'Recheck' && task.source === 'diagnosis'; const brief = getJournalTaskBrief(task); // Seamless visuals (Bekky, 2026-08-20): no checkbox box — the task's own // emoji (💧/🌱/🩺) is the visual cue, and the ENTIRE row is tappable to // complete (which opens the confirm modal). Tapping completes. const icon = task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'; return ( {icon} {/* LINE 1 — the FULL title (e.g. "Treat Sweet Osmanthus"), wraps if long. No space/due crammed on this line (Bekky, 2026-08-12). */} {task.type} {task.plantName} {/* LINE 2 — the sentence description (struck through with the title when completed — a thin pencil line through the whole script) */} {!hideBrief && brief ? ( {brief} ) : null} {/* LINE 3 — the action buttons + space name + due day, EVENLY spaced (Bekky, 2026-08-12: "evenly spaced between skip, view case, side garden, today"). */} {canShowActions ? ( isRecheck ? ( 📸 Check In ) : ( Skip ) ) : null} {onViewCase ? ( {task.source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} {task.spaceName ? {task.spaceName} : null} {task.dueLabel} ); } /** * A grouped Due-Today row (Bekky, 2026-08-20): same action + same space + same * due window collapse into ONE journal line — e.g. "💧 The Garden — 5 plants * ready to water today" — with a "Water all" action. Tapping the row expands * the individual plants (each still opens the conditional check-in modal). * A plant with a special need (different due day / diagnosis / note) is never * swallowed into a group — it renders as its own row. */ export function JournalGroupRow({ groupKey, actionLabel, spaceName, count, dueLabel, expanded, tasks, actionStates, onToggle, onCompleteAll, onComplete, onSkip, onViewCase, }: { groupKey: string; actionLabel: string; spaceName: string; count: number; dueLabel: string; expanded: boolean; tasks: CareTaskItem[]; actionStates: Record; onToggle: () => void; onCompleteAll: () => void; onComplete: (task: CareTaskItem) => void; onSkip: (task: CareTaskItem) => void; onViewCase?: (task: CareTaskItem) => void; }) { const icon = actionLabel === 'Water' ? '💧' : actionLabel === 'Fertilize' ? '🌱' : '🩺'; return ( {/* Space header — same green treatment as the Upcoming boxes (Bekky, 2026-08-20): the space name sits ABOVE the action row, not inline with "ready to water". */} {spaceName} {/* Action dropdown row — matches Upcoming's group row: icon + action + count · due + chevron */} {icon} {actionLabel} {count} {count === 1 ? 'plant' : 'plants'} · {dueLabel} {expanded ? '⌃' : '⌄'} {expanded ? ( {/* "Water all" lives INSIDE the dropdown now (Bekky, 2026-08-20) — not on the header line. */} {actionLabel} all {tasks.map(task => ( onComplete(task)} onSkip={() => onSkip(task)} onViewCase={onViewCase ? () => onViewCase(task) : undefined} hideBrief /> ))} ) : null} ); } /** An upcoming task as a lighter handwritten entry (no actions yet). */ export function JournalUpcomingRow({ task, actionState, onComplete, onSkip, onViewCase, hideBrief = false, }: { task: CareTaskItem; actionState?: CareTaskActionState; onComplete?: () => void; onSkip?: () => void; onViewCase?: (task: CareTaskItem) => void; /** Hide the 2nd-line sentence (Bekky, 2026-08-20): in grouped Upcoming rows the brief duplicates the title ("Water Mexican Heather" twice), so drop it there. */ hideBrief?: boolean; }) { const done = actionState?.status === 'completed'; const skipped = actionState?.status === 'skipped'; // Power to the user (Bekky, 2026-08-18→20): upcoming tasks CAN be completed // ahead of schedule, but ONLY if due within 3 days. A task due next week or // next month stays read-only (its checkbox is not tappable yet). const canComplete = !!onComplete && !done && !skipped && canCompleteCareTaskEarly(task); const brief = getJournalTaskBrief(task); // Seamless visuals (Bekky, 2026-08-20): no checkbox box — the task's own // emoji is the cue and the ENTIRE row is tappable to complete. const icon = task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'; return ( {icon} {/* LINE 1 — the FULL title */} {task.type} {task.plantName} {/* LINE 2 — the sentence description (struck through when completed) */} {!hideBrief && brief ? ( {brief} ) : null} {/* LINE 3 — the action button + space name + due day, EVENLY spaced */} {onViewCase ? ( onViewCase(task))}> {task.source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} {task.spaceName ? {task.spaceName} : null} {task.dueLabel} ); } /** * A Due-Today SPACE group (Bekky, 2026-08-22 redesign). Groups due-today tasks * by SPACE (not action), one collapsible per space: * - 1 plant → listed directly under the space name, no chevron * - 2+ plants → "N plants today" + chevron; expand to reveal the plants * - A plant needing BOTH water + fertilize renders as a small dropdown * (chevron) → tapping reveals 💧 Water / 🌱 Fertilize as separate rows. * The plant COUNT is DISTINCT plants (a plant with 2 tasks counts once). * No repeated space name or "Today" inside the expanded rows — the space * header already told you. Skip lives in the care-refinement modal (not here). */ export function DueTodaySpaceGroup({ spaceName, plantGroups, actionStates, spaceExpanded, onToggleSpace, expandedPlants, onTogglePlant, onComplete, onCompleteAll, onViewCase, hideTitle = false, }: { spaceName: string; /** One entry per DISTINCT plant due today in this space. */ plantGroups: { plantId: string; plantName: string; /** The tasks due for this plant (1 = water or fertilize; 2 = both). */ tasks: CareTaskItem[]; }[]; actionStates: Record; spaceExpanded: boolean; onToggleSpace: () => void; expandedPlants: Set; onTogglePlant: (plantId: string) => void; onComplete: (task: CareTaskItem) => void; /** Complete every Water (or Fertilize) task in this space in one go. */ onCompleteAll: (action: string) => void; onViewCase?: (task: CareTaskItem) => void; /** Hide the space title (Bekky, 2026-08-29): when cohort rows are folded in * above the standalone plants, the space title is rendered once by the caller. */ hideTitle?: boolean; }) { const count = plantGroups.length; // Per-action "all" buttons — a space may hold both water AND fertilize plants. const waterTasks = plantGroups.flatMap(p => p.tasks).filter(t => t.type === 'Water'); const fertTasks = plantGroups.flatMap(p => p.tasks).filter(t => t.type === 'Fertilize'); const pendingWater = waterTasks.filter(t => actionStates[t.id]?.status !== 'completed'); const pendingFert = fertTasks.filter(t => actionStates[t.id]?.status !== 'completed'); return ( {/* Space header — green treatment, same as the Upcoming boxes. */} {!hideTitle ? {spaceName} : null} {/* "Water all" / "Fertilize all" — for a space with many plants watered in one pass (Bekky, 2026-08-22). Only shown when 2+ of an action are pending, so a single plant still gets the individual tap. */} {(pendingWater.length > 1 || pendingFert.length > 1) ? ( {pendingWater.length > 1 ? ( onCompleteAll('Water'), haptic.success)}> 💧 Water all ({pendingWater.length}) ) : null} {pendingFert.length > 1 ? ( onCompleteAll('Fertilize'), haptic.success)}> 🌱 Fertilize all ({pendingFert.length}) ) : null} ) : null} {count === 1 ? ( /* Single plant — listed directly, no chevron. If it needs 2 actions it still shows as a small dropdown so the user can pick which to do. */ onTogglePlant(plantGroups[0].plantId)} onComplete={onComplete} onViewCase={onViewCase} /> ) : ( <> {/* Multi-plant: collapsible "N plants today" header. */} {count} {count === 1 ? 'plant' : 'plants'} today {spaceExpanded ? '⌃' : '⌄'} {spaceExpanded ? ( {plantGroups.map(plant => ( onTogglePlant(plant.plantId)} onComplete={onComplete} onViewCase={onViewCase} /> ))} ) : null} )} ); } /** A single plant's row inside a Due-Today space group. Shows the plant name + * its action emoji(s); if it needs BOTH water+fertilize it's a small dropdown. */ function DueTodayPlantRow({ plant, actionStates, expanded, onToggle, onComplete, onViewCase, }: { plant: { plantId: string; plantName: string; tasks: CareTaskItem[] }; actionStates: Record; expanded: boolean; onToggle: () => void; onComplete: (task: CareTaskItem) => void; onViewCase?: (task: CareTaskItem) => void; }) { const multi = plant.tasks.length > 1; const doneCount = plant.tasks.filter(t => actionStates[t.id]?.status === 'completed').length; const allDone = doneCount === plant.tasks.length; const icons = plant.tasks.map(t => (t.type === 'Water' ? '💧' : t.type === 'Fertilize' ? '🌱' : '🩺')).join(''); return ( onComplete(plant.tasks[0]), multi ? undefined : haptic.success)} disabled={allDone} accessibilityRole="button" accessibilityState={{ expanded: multi ? expanded : undefined }} accessibilityLabel={`${multi ? (expanded ? 'Collapse' : 'Expand') : 'Complete'} ${plant.plantName}`} > {icons} {plant.plantName} {/* Single action (1 task): show the action inline so the row is clear. */} {!multi ? ( {plant.tasks[0].type} ) : ( {doneCount}/{plant.tasks.length} done )} {onViewCase ? ( onViewCase(plant.tasks[0]))}> {plant.tasks[0].source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} {multi ? {expanded ? '⌃' : '⌄'} : null} {multi && expanded ? ( {plant.tasks.map(task => { const done = actionStates[task.id]?.status === 'completed'; const icon = task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'; return ( onComplete(task), haptic.success)} disabled={done} accessibilityRole="button" accessibilityLabel={`${task.type} ${plant.plantName}`} > {icon} {task.type} {plant.plantName} {done ? : null} ); })} ) : null} ); } /** * An UPCOMING plant row used in the By-space and By-action views (Bekky, * 2026-08-22). Left: emoji + plant name (vertically centered). Right: a * two-line stack — space name on top, View Care/Case button directly beneath. * The plant name's vertical center aligns with the middle of that stack so the * row stays compact. Action word + due day live in the group header, never * repeated on rows. */ export function UpcomingPlantRow({ task, actionState, onComplete, onViewCase, dueLabel, }: { task: CareTaskItem; actionState?: CareTaskActionState; onComplete?: () => void; onViewCase?: (task: CareTaskItem) => void; /** When set, the row shows "in X days" beneath the plant name (single-plant * case — no group header/chevron). When absent, the row is a plain plant * row inside a multi-plant group body. (Bekky, 2026-09-02) */ dueLabel?: string; }) { const done = actionState?.status === 'completed'; const skipped = actionState?.status === 'skipped'; const canComplete = !!onComplete && !done && !skipped && canCompleteCareTaskEarly(task); const icon = task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'; const wxAdj = task.weatherAdjusted === true; // View Care only matters for near-term items (≤3 days) — a task due in 7-10 // days (or postponed by weather) doesn't need the deep-link yet (Bekky, // 2026-09-02). Space name is redundant inside a space tile, so it's dropped. const days = daysUntilDue(task); const showViewCare = !!onViewCase && days !== null && days <= 3; return ( {/* LINE 1: plant name on the left, due label on the right of the SAME line, View Care button on the right too when due ≤3 days (Bekky, 2026-09-02). */} {icon} {task.plantName} {dueLabel ? ( {dueLabel} ) : null} {showViewCare ? ( onViewCase(task))}> {task.source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} {/* LINE 2: weather note spans the FULL page width below the name/due line (Bekky, 2026-09-02) — no more narrow right column. */} {task.weatherNote ? ( {task.weatherNote} ) : null} ); } /** * A collapsible UPCOMING group (Bekky, 2026-08-22). Groups by DUE DAY: shows * "N plants · In X days" + chevron; tapping reveals the plant rows. A 1-plant * group renders the row directly (no chevron) — cohesive with Due Today. */ export function UpcomingDayGroup({ dueLabel, count, expanded, onToggle, tasks, actionStates, onComplete, onViewCase, }: { dueLabel: string; count: number; expanded: boolean; onToggle: () => void; tasks: CareTaskItem[]; actionStates: Record; onComplete?: (task: CareTaskItem) => void; onViewCase?: (task: CareTaskItem) => void; }) { const canCompleteAny = tasks.some(t => canCompleteCareTaskEarly(t)); // Group the day's tasks by DISTINCT plant (Bekky, 2026-08-22). A plant with // 2 actions on the same day renders as a small dropdown (cohesive with Due // Today) so the brain never misses that one plant needs two things. const byPlant = new Map(); for (const t of tasks) { const arr = byPlant.get(t.plantId) || []; arr.push(t); byPlant.set(t.plantId, arr); } const plants = Array.from(byPlant.entries()).map(([plantId, plantTasks]) => ({ plantId, plantName: plantTasks[0].plantName, tasks: plantTasks, })); const renderPlant = (plant: { plantId: string; plantName: string; tasks: CareTaskItem[] }) => { const multi = plant.tasks.length > 1; if (!multi) { const t = plant.tasks[0]; return ( onComplete?.(t) : undefined} onViewCase={onViewCase} /> ); } return ( onComplete?.(t) : undefined} onViewCase={onViewCase} /> ); }; const actionIcons = Array.from(new Set(tasks.map(t => t.type === 'Water' ? '💧' : t.type === 'Fertilize' ? '🌱' : t.type === 'Recheck' ? '🩺' : '🌿'))).join(' '); // SINGLE-PLANT CASE (Bekky, 2026-09-02): one plant with one task → render the // plant row DIRECTLY (name + "in X days" beneath, no chevron, no collapse). // A chevron/dropdown for a single plant is pointless, and it was hiding the // gray-out + weather note behind the collapsed body. Multi-plant groups keep // the collapsible header. const singlePlant = plants.length === 1 && plants[0].tasks.length === 1; if (singlePlant) { const t = plants[0].tasks[0]; return ( onComplete?.(t) : undefined} onViewCase={onViewCase} dueLabel={dueLabel} /> ); } return ( {actionIcons ? `${actionIcons} ` : ''}{count} {count === 1 ? 'plant' : 'plants'} · {dueLabel} {expanded ? '⌃' : '⌄'} {expanded ? ( {plants.map(renderPlant)} ) : null} ); } /** A plant with 2+ actions on the same day — a small dropdown revealing each * action separately (cohesive with Due Today's DueTodayPlantRow). */ function UpcomingMultiActionRow({ plant, actionStates, onComplete, onViewCase, }: { plant: { plantId: string; plantName: string; tasks: CareTaskItem[] }; actionStates: Record; onComplete?: (task: CareTaskItem) => void; onViewCase?: (task: CareTaskItem) => void; }) { const [open, setOpen] = useState(false); const doneCount = plant.tasks.filter(t => actionStates[t.id]?.status === 'completed').length; const allDone = doneCount === plant.tasks.length; const icons = plant.tasks.map(t => (t.type === 'Water' ? '💧' : t.type === 'Fertilize' ? '🌱' : '🩺')).join(''); return ( setOpen(o => !o))} disabled={allDone} accessibilityRole="button" accessibilityState={{ expanded: open }} > {icons} {plant.plantName} {doneCount}/{plant.tasks.length} done {onViewCase ? ( onViewCase(plant.tasks[0]))}> {plant.tasks[0].source === 'diagnosis' ? 'View Case' : 'View Care'} ) : null} {open ? '⌃' : '⌄'} {open ? ( {plant.tasks.map(task => { const done = actionStates[task.id]?.status === 'completed'; const icon = task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'; return ( onComplete?.(task), haptic.success)} disabled={done} accessibilityRole="button" > {icon} {task.type} {plant.plantName} {done ? : null} ); })} ) : null} ); } /** A completed task row, struck-through like a pencilled note. */ export function JournalCompletedRow({ task, completedAt, expanded, actionState, onToggle, }: { task: CareTaskItem; completedAt?: string; expanded: boolean; actionState?: CareTaskActionState; onToggle: () => void; }) { return ( {task.type === 'Water' ? '💧' : task.type === 'Fertilize' ? '🌱' : '🩺'} {task.type} {task.plantName} {task.spaceName || 'No space assigned'}{completedAt ? ' · Just now' : ' · Today'} {expanded ? ( {getCareTaskDetailStatus(task, actionState)} ) : null} ); } /** Pale pencil-style empty state written in the margin. */ export function JournalEmpty({ text }: { text: string }) { return ~ {text} ~; } /** * A journal section with a COMPUTER-GENERATED hand-drawn green border that grows * with its content (so "Due Today" with 10 items and "Upcoming" with 3 both fit). * The border is a slightly-wobbly sage-green rounded rectangle, styled to look * hand-drawn (not a perfect computer rectangle). NO ruled lines behind the * content — Bekky found they fought the text, so the paper stays clean. */ export function JournalSection({ children, style, }: { children: React.ReactNode; style?: StyleProp; }) { return ( {children} ); } /** * COHORT MEMBERS MODAL (Bekky, 2026-09-01) — tap the group NAME to open a * pop-up of clickable tiles, one per plant in the group, so you can instantly * see exactly which plants belong together. Each tile is tappable → opens the * plant's rich check-in (same as tapping the plant row inside the expanded * group). The group name is the convenient "refresh my memory" link. */ export function CohortMembersModal({ visible, cohortName, action, plantIds, plantNames, plantPhotos, onClose, onPlantPress, onPlantDetailPress, onCompleteAll, }: { visible: boolean; cohortName: string; action: 'water' | 'fertilize'; plantIds: string[]; plantNames: string[]; /** Plant photo URIs, keyed by raw plantId (Bekky, 2026-09-01): the same * photo the garden page tiles show, so you can recognize each plant. */ plantPhotos?: Record; onClose: () => void; /** Tap a tile → open the plant's rich check-in (cadence) modal. */ onPlantPress?: (plantId: string) => void; /** Tap a tile → open the plant DETAIL page (Bekky, 2026-09-01: the modal * tiles should take you to the plant detail, like the garden tiles do). */ onPlantDetailPress?: (plantId: string) => void; /** Complete the whole group in one tap (Bekky, 2026-09-01): the modal's * primary action, side-by-side with Done. */ onCompleteAll?: () => void; }) { const icon = action === 'water' ? '💧' : '🌱'; // EdgeModal-style wrapper so statusBarTranslucent/navigationBarTranslucent type-check. const EdgeModal = Modal as any; // Enlarged photo viewer (Bekky, 2026-09-02): tap a member's photo to enlarge it // and see exactly which plant it is. null = viewer closed. The photo tap does // NOT navigate (avoids a doom-loop) — only the tile's name/copy area opens the // plant detail. const [enlargedPhoto, setEnlargedPhoto] = useState(null); return ( <> {icon} {humanizeCareProse(cohortName)} {plantNames.length} {plantNames.length === 1 ? 'plant' : 'plants'} in this group {plantNames.map((name, i) => { const pid = plantIds[i]; const photo = pid ? plantPhotos?.[pid] : undefined; return ( { // Tap a member tile → ENLARGE the photo (Bekky, 2026-09-02): // the members modal is a "who's in this group" viewer. Making // the tap navigate (to plant detail / check-in) created a // doom-loop risk on an informational, non-actionable cohort. // Enlarging the photo is the only action — look closely, then // Back. (Plant detail is still reachable from the Care-tab // dropdown rows / the garden.) if (photo) setEnlargedPhoto(photo); })} accessibilityRole="button" accessibilityLabel={photo ? `${name} — enlarge photo` : name} > {photo ? ( ) : ( {icon} )} {humanizeCareProse(name)} ); })} {onCompleteAll ? ( { onClose(); onCompleteAll(); }, haptic.success)} accessibilityRole="button" accessibilityLabel={`Complete all ${plantNames.length} plants in this group`} > {icon} Complete all ({plantNames.length}) ) : null} Back {/* Enlarged member photo (Bekky, 2026-09-02): tap a member tile to enlarge its photo / see the plant up close. Simple + no navigation (no doom-loop). */} setEnlargedPhoto(null)} statusBarTranslucent navigationBarTranslucent > setEnlargedPhoto(null), haptic.light)} /> {enlargedPhoto ? : null} setEnlargedPhoto(null), haptic.light)} accessibilityRole="button" accessibilityLabel="Close enlarged photo"> Close ); } /** * COHORT GROUP row (Bekky, 2026-08-27, Chunk 2; restyled 2026-08-29) — a sweepable * unit in the Care tab. One per cohort the AI derived for a space+action. Tapping * it expands to reveal the individual plants (each tappable → rich check-in modal) * + the cohort-wide two-button feedback toggle. Renders with the journal's Caveat * styling so it reads like the rest of the Care journal, not a separate island. */ export function CohortGroup({ cohort, action, plantIds, plantNames, dueLabel, expanded, onToggle, onPlantPress, onPlantDetailPress, onWetDryFeedback, plantDeltas, onCompleteAll, skippedMembers, actionable, soilCanDry = true, onSoilCheckComplete, onSoilCheckPostpone, plantPhotos, }: { cohort: { id: string; name: string; plantIds: string[]; sharedCadence?: Partial>; note?: string; }; action: 'water' | 'fertilize'; plantIds: string[]; plantNames: string[]; /** The actual due day (e.g. "Today" / "In 3 days") — NOT the cadence interval * (Bekky, 2026-08-29: "it needs to be telling me what day it's actually due"). */ dueLabel?: string; expanded: boolean; onToggle: () => void; /** Tap an individual plant → open its rich check-in modal (Bekky, 2026-08-29). */ onPlantPress?: (plantId: string) => void; /** Tap a members-modal tile → open the plant DETAIL page (Bekky, 2026-09-01). */ onPlantDetailPress?: (plantId: string) => void; /** Cohort-wide two-button feedback (Bekky, 2026-08-29): water = dry/wet, * fertilize = growing/not-growing. Adjusts the shared cadence. */ onWetDryFeedback?: (signal: 'dry' | 'wet') => void; /** Per-plant AI-reasoning delta (Chunk A, Bekky 2026-08-29): a simple sentence * explaining why THIS plant's care is similar-but-slightly-different from the * group (e.g. "this one's in a terracotta pot so it dries faster"). Keyed by * raw plantId. No template — the AI writes it. */ plantDeltas?: Record; /** Complete the whole group in one tap (Chunk A, Bekky 2026-08-29): the group * is the unit of care — check off all members together. */ onCompleteAll?: () => void; /** Members held out this round (Bekky, 2026-08-29): a recent manual water (last * watered) put them off the shared schedule. They stay visible in the group, * grayed out, with the note. Keyed by raw plantId → the "last watered" date. */ skippedMembers?: Record; /** Whether this group is actionable NOW (Bekky, 2026-08-29): a group whose next * due is beyond the ≤3-day early window must NOT show Complete-all or the * wet/dry feedback buttons — it isn't actionable yet. */ actionable?: boolean; /** SOIL-CAN'T-DRY (Bekky, 2026-09-01, Chunk 2): when the weather keeps the soil * from drying (extreme rain / overwatering risk / frost / cold-damp), the * cohort's quick buttons (Complete-all + the two wet/dry feedback pills) are * HIDDEN — the user must CHECK THE SOIL first via the check-in modal (slider * + postpone) before deciding. Default true (soil can dry → normal buttons). */ soilCanDry?: boolean; /** SOIL-CHECK MODAL (Bekky, 2026-09-01, Chunk 2): when soilCanDry is false, * tapping the cohort opens this multi-select check-in modal instead of the * quick buttons. Complete the SELECTED plants with the slider state. */ onSoilCheckComplete?: (plantIds: string[], stateAtComplete: string) => void; /** Postpone the SELECTED plants by N days. */ onSoilCheckPostpone?: (plantIds: string[], stateAtComplete: string, postponeDays: number) => void; /** Plant photo URIs keyed by raw plantId (Bekky, 2026-09-01): shown on the * members modal tiles so you can recognize each plant. */ plantPhotos?: Record; }) { const icon = action === 'water' ? '💧' : '🌱'; const cadence = cohort.sharedCadence?.[action]; const count = plantNames.length; const isWater = action === 'water'; const actionableNow = actionable !== false; // Two-button toggle wording (Bekky, 2026-08-29): water = moisture, fertilize = // growth. Three-word pairs, matchy-matchy. const dryLabel = isWater ? 'Drying too fast' : 'Pushing new growth'; const wetLabel = isWater ? 'Still holding water' : 'Not growing much'; const [membersOpen, setMembersOpen] = useState(false); const [soilCheckOpen, setSoilCheckOpen] = useState(false); return ( {/* Header: the group NAME is the tap-to-see-members link (Bekky, 2026-09-01); the chevron is confined to its own corner and is the ONLY expand/collapse control. Tapping the name opens the members tile modal instead. */} {icon} { // SOIL-CAN'T-DRY (Bekky, 2026-09-01, Chunk 2): when the weather keeps // the soil from drying AND the cohort is actionable NOW (due today / // within the ≤3-day window), tapping the cohort opens the multi-select // soil-check modal (slider + Complete/Postpone) — the user must check // the soil before deciding. // (Bekky, 2026-09-02 fix): a NOT-yet-actionable cohort (>3 days out) // must NOT open the soil-check modal — there's nothing to check yet. // It opens the members list instead (harmless "who's in this group"). if (!soilCanDry && actionableNow && onSoilCheckComplete) setSoilCheckOpen(true); else setMembersOpen(true); })} accessibilityRole="button" accessibilityLabel={`${humanizeCareProse(cohort.name)} — see group members`} > {humanizeCareProse(cohort.name)} {dueLabel ? ( {count} {count === 1 ? 'plant' : 'plants'} · {dueLabel} ) : cadence ? ( every {cadence} days · {count} {count === 1 ? 'plant' : 'plants'} ) : ( {count} {count === 1 ? 'plant' : 'plants'} )} {expanded ? '⌃' : '⌄'} setMembersOpen(false)} onPlantPress={onPlantPress} onPlantDetailPress={onPlantDetailPress} onCompleteAll={actionableNow && onCompleteAll ? onCompleteAll : undefined} /> ({ id, name: plantNames[i] || id, photo: id ? plantPhotos?.[id] : undefined }))} onCancel={() => setSoilCheckOpen(false)} onComplete={(ids, stateAtComplete) => { setSoilCheckOpen(false); onSoilCheckComplete?.(ids, stateAtComplete); }} onPostpone={(ids, stateAtComplete, days) => { setSoilCheckOpen(false); onSoilCheckPostpone?.(ids, stateAtComplete, days); }} /> {expanded ? ( {cohort.note ? {humanizeCareProse(cohort.note)} : null} {!soilCanDry ? ( {actionableNow ? 'The soil may be too wet to water — tap a plant to check it before deciding.' : 'the soil is still too wet from the rain to dry out, so watering\'s been pushed back — Pixie is monitoring the situation.'} ) : null} {plantNames.map((name, i) => { const pid = plantIds[i]; const delta = pid ? plantDeltas?.[pid] : undefined; const skipNote = pid ? skippedMembers?.[pid] : undefined; // A plant row is only tappable (opens the cadence wheel / check-in) // when the group is actionable NOW (Bekky, 2026-08-29). A group more // than 3 days out is informational — its members must NOT open the // wheel yet. Non-actionable rows render as plain text, no chevron. // (Bekky, 2026-09-01: confirmed this gating is CORRECT — the cadence // modal should only open for tasks that are completable now. A brief // experiment made rows always-tappable; that was wrong and reverted.) const tappable = actionableNow && !skipNote; const content = ( <> • {name} {delta ? {humanizeCareProse(delta)} : skipNote ? {humanizeCareProse(skipNote)} : null} {tappable ? : null} ); return tappable ? ( onPlantPress?.(pid))} accessibilityRole="button" accessibilityLabel={`${name} — open care check-in`} > {content} ) : ( {content} ); })} {actionableNow && soilCanDry && onCompleteAll ? ( {icon} Complete all ({count}) ) : null} {actionableNow && soilCanDry && onWetDryFeedback ? ( onWetDryFeedback('dry'))} > {icon} {dryLabel} onWetDryFeedback('wet'))} > {wetLabel} ) : null} ) : null} ); } // ---- Styles ---- const s = StyleSheet.create({ iconBox: { width: 64, height: 64, alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, iconImage: { width: 60, height: 60 }, card: { backgroundColor: 'rgba(255,253,247,0.96)', borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', marginBottom: 8, overflow: 'hidden' }, cardExpanded: { borderColor: green }, // Weather-adjusted (Bekky, 2026-09-02): a task whose due date weather pushed // past the Upcoming window is KEPT but grayed so it reads as "not a current // task" — the whole space no longer vanishes. Note tells the real story. cardWeatherAdjusted: { opacity: 0.5, borderColor: '#C9C4B7', backgroundColor: 'rgba(245,242,235,0.7)' }, textWeatherAdjusted: { color: '#9A9588' }, mainRow: { flexDirection: 'row', alignItems: 'center', gap: 10, minHeight: 66, padding: 8 }, copy: { flex: 1, minWidth: 0 }, rowTitle: { color: dark, fontSize: 14, fontWeight: '800' }, small: { color: '#78694B', fontSize: 11, marginTop: 2 }, hint: { color: '#9E9E9E', fontSize: 10, marginTop: 2 }, weatherNote: { color: '#3E7D3A', fontSize: 11, lineHeight: 14, fontWeight: '600', marginTop: 4 }, statusColumn: { alignItems: 'flex-end', gap: 4 }, due: { color: '#78694B', fontSize: 12, fontWeight: '600' }, detailsPill: { color: green, fontSize: 12, fontWeight: '700', backgroundColor: '#F3F7E6', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 999 }, expandedPanel: { paddingHorizontal: 12, paddingBottom: 12, borderTopWidth: 1, borderTopColor: 'rgba(191,217,180,0.5)' }, detailStatus: { color: '#78694B', fontSize: 13, fontWeight: '600', marginTop: 10, marginBottom: 8 }, detailSections: { gap: 10 }, guidanceBlock: { backgroundColor: '#F3F7E6', borderRadius: 12, padding: 10 }, guidanceTitle: { color: dark, fontSize: 13, fontWeight: '800', marginBottom: 4 }, guidanceText: { color: '#5B7553', fontSize: 13, lineHeight: 18 }, actionRow: { flexDirection: 'row', gap: 10, marginTop: 10 }, recheckActionRow: { flexDirection: 'row', gap: 8, marginTop: 10 }, recheckQuietButton: { flex: 1, minHeight: 44, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center' }, recheckQuietText: { color: dark, fontSize: 13, fontWeight: '700' }, recheckCancelButton: { minHeight: 44, borderRadius: 14, borderWidth: 1.5, borderColor: '#B85C4A', backgroundColor: '#FCE9E6', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 14 }, recheckCancelText: { color: '#8E2F1E', fontSize: 13, fontWeight: '800' }, actionButton: { flex: 1, minHeight: 44, borderRadius: 14, backgroundColor: green, alignItems: 'center', justifyContent: 'center' }, actionText: { color: '#fff', fontSize: 14, fontWeight: '800' }, actionButtonQuiet: { flex: 1, minHeight: 44, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF7', alignItems: 'center', justifyContent: 'center' }, actionTextQuiet: { color: dark, fontSize: 14, fontWeight: '700' }, futureNote: { color: '#9E9E9E', fontSize: 12, fontStyle: 'italic', marginTop: 10 }, upcomingGroup: { marginBottom: 16 }, groupTitle: { color: dark, fontSize: 16, fontWeight: '900', marginBottom: 8 }, compactEmpty: { color: '#9E9E9E', fontSize: 13, fontStyle: 'italic', padding: 12 }, completedRowWrap: { borderRadius: 18, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', marginBottom: 8, overflow: 'hidden' }, completedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, minHeight: 56, padding: 12 }, compactTitle: { color: dark, fontSize: 14, fontWeight: '700', textDecorationLine: 'line-through' }, compactMeta: { color: '#9E9E9E', fontSize: 11, marginTop: 2 }, feedbackBanner: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: '#F1F8E6', borderWidth: 1, borderColor: '#D6E7BE', borderRadius: 14, paddingVertical: 6, paddingHorizontal: 10, marginTop: 4, marginBottom: 3 }, feedbackText: { flex: 1, color: '#5B7553', fontSize: 13, fontWeight: '600' }, undoButton: { minHeight: 32, borderRadius: 999, borderWidth: 1, borderColor: '#D6E7BE', paddingHorizontal: 12, paddingVertical: 4, alignItems: 'center', justifyContent: 'center' }, undoText: { color: dark, fontSize: 12, fontWeight: '700' }, }); // ---- Journal entry styles (handwritten gardener's notebook) ---- // Warm olive/forest green ink, storybook serif headings, Caveat handwriting. const j = StyleSheet.create({ checkbox: { borderWidth: 1.6, borderColor: '#55745E', backgroundColor: 'rgba(253,247,235,0.4)', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginRight: 4, }, checkboxDone: { borderColor: '#2E6B3F', backgroundColor: 'rgba(46,107,63,0.12)', }, check: { color: '#2E6B3F', fontWeight: '800', // Render the ✓ in the handwriting font (Caveat) so it looks pencil-drawn, // matching the journal's script (Bekky, 2026-08-12). fontFamily: 'Caveat', }, rowWrap: { marginBottom: 4, }, // Weather-adjusted (Bekky, 2026-09-02): the row is kept but grayed so it // reads as "not a current task" — the plant no longer vanishes from the list. rowWrapWxAdj: { opacity: 0.5, }, rowTitleWxAdj: { color: '#9A9588', }, row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 7, paddingHorizontal: 2, minHeight: 46, }, rowCopy: { flex: 1, minWidth: 0, marginLeft: 6, }, // The task's action emoji as the completion cue (no checkbox box) — the // whole row is tappable (Bekky, 2026-08-20). rowIcon: { fontSize: 20, lineHeight: 26, marginTop: 3, marginRight: 2, }, rowIconDone: { opacity: 0.45, }, titleLine: { flexDirection: 'row', alignItems: 'baseline', // NO wrap — the due label must stay on the FIRST line with the title and // space (Bekky, 2026-08-12). The title shrinks/truncates if needed so the // due label never drops to a second line. flexWrap: 'nowrap', gap: 6, }, rowTitle: { color: '#2E4B35', fontSize: 20, fontFamily: 'Caveat', lineHeight: 23, flexShrink: 1, }, rowTitleDone: { color: '#8A9B84', textDecorationLine: 'line-through', }, rowMeta: { color: '#5D4E2B', fontSize: 17, fontFamily: 'Caveat', lineHeight: 19, }, rowBrief: { color: '#5B7553', fontSize: 18, fontFamily: 'Caveat', lineHeight: 21, marginTop: 1, }, // Weather note on a single-plant upcoming row (Bekky, 2026-09-02): the // "adjusted due to weather" line, shown in the right stack between the space // name and View Care so it wraps FULLY (never truncated). Left-aligned with a // max width so a long note wraps cleanly instead of stretching the row. weatherNote: { color: '#3E7D3A', fontSize: 11, lineHeight: 14, fontWeight: '600', marginTop: 2, alignSelf: 'flex-start', maxWidth: 150, }, // Weather note spanning the FULL page width below the name/due line on a // single-plant upcoming row (Bekky, 2026-09-02). Reads like a journal line // under the entry, not a cramped right column. weatherNoteFull: { color: '#3E7D3A', fontSize: 11, lineHeight: 14, fontWeight: '600', marginTop: 2, marginLeft: 30, paddingRight: 8, }, // Completed: a thin pencil line through the sentence (Bekky, 2026-08-12). rowBriefDone: { color: '#8A9B84', textDecorationLine: 'line-through', textDecorationColor: 'rgba(46,107,63,0.55)', textDecorationStyle: 'solid', }, rowDue: { color: '#5D4E2B', fontSize: 18, fontFamily: 'Caveat', marginLeft: 6, }, // LINE 3 of a journal entry: action buttons + space + due EVENLY spaced // across the row (Bekky, 2026-08-12). metaActionLine: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 4, }, checkboxHit: { padding: 4, marginRight: 0, alignItems: 'center', justifyContent: 'center', }, compactActionRow: { flexDirection: 'row', gap: 8, marginTop: 2, marginLeft: 34, marginBottom: 4, }, // Small tape-like action labels stuck on the journal (not tech buttons). // SLIM (Bekky, 2026-08-12: "too fat") — reduced height + horizontal padding. tapeSticker: { minHeight: 20, borderRadius: 4, borderWidth: 1, borderColor: 'rgba(85,116,94,0.4)', backgroundColor: 'rgba(253,247,235,0.6)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 7, }, tapeStickerText: { color: '#2E4B35', fontSize: 13, fontFamily: 'Caveat', }, expandedPanel: { paddingHorizontal: 14, paddingBottom: 8, borderLeftWidth: 1, borderLeftColor: 'rgba(191,217,180,0.6)', marginLeft: 18, }, detailStatusDone: { color: '#8A9B84', fontSize: 14, fontFamily: 'Caveat', marginTop: 2, marginBottom: 4, }, actionRow: { flexDirection: 'row', gap: 10, marginTop: 10, flexWrap: 'wrap', }, stickerPrimary: { minHeight: 34, borderRadius: 8, backgroundColor: 'rgba(46,107,63,0.88)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 14, }, stickerPrimaryText: { color: '#fff', fontSize: 13, fontWeight: '700', }, stickerQuiet: { minHeight: 34, borderRadius: 8, borderWidth: 1, borderColor: 'rgba(85,116,94,0.55)', backgroundColor: 'rgba(253,247,235,0.55)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, }, stickerQuietText: { color: '#2E4B35', fontSize: 13, fontWeight: '600', }, viewCaseSticker: { alignSelf: 'flex-start', minHeight: 32, borderRadius: 8, borderWidth: 1, borderColor: 'rgba(85,116,94,0.55)', paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center', marginTop: 8, }, viewCaseText: { color: '#2E4B35', fontSize: 12, fontWeight: '600', }, futureNote: { color: '#9E9E9E', fontSize: 13, fontFamily: 'Caveat', marginTop: 6, }, sectionTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 6, marginBottom: 4, }, sectionTitle: { color: '#2E6B3F', fontSize: 24, fontFamily: 'Alegreya', // NOTE: no fontWeight here — Alegreya ships a single weight, and adding // fontWeight makes React Native fall back to the system sans-serif font // (which is why headings looked like "computer text" while Caveat task // text rendered handwritten). Let the font's own weight render. }, sectionCountBox: { minWidth: 24, height: 24, borderRadius: 6, backgroundColor: 'rgba(243,247,230,0.9)', borderWidth: 1, borderColor: 'rgba(85,116,94,0.6)', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5, }, sectionCountText: { color: '#2E6B3F', fontSize: 13, fontWeight: '700', }, subTitle: { color: '#4A6741', fontSize: 19, fontFamily: 'Caveat', marginTop: 6, marginBottom: 2, }, emptyNote: { color: '#A9B4A0', fontSize: 15, fontFamily: 'Caveat', fontStyle: 'italic', paddingVertical: 8, }, // ---- JournalSection: computer-generated hand-drawn green border (no ruled lines) ---- section: { borderWidth: 1.6, borderColor: 'rgba(85,116,94,0.55)', // sage-green, hand-drawn feel borderRadius: 14, backgroundColor: 'rgba(253,247,235,0.35)', // faint paper tint marginBottom: 10, overflow: 'hidden', position: 'relative', }, sectionInner: { paddingHorizontal: 12, paddingVertical: 6, }, // ---- Grouped Due-Today row (Bekky, 2026-08-20; restyled 2026-08-20 to match Upcoming) ---- groupWrap: { marginBottom: 6, }, // Space header — green Caveat title above the action row, like Upcoming's box title. groupSpaceTitle: { color: '#2E6B3F', fontSize: 18, fontFamily: 'Caveat', marginBottom: 2, marginLeft: 2, }, groupHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: 'rgba(85,116,94,0.15)', }, // The action word ("💧 Water") — same Caveat 20 as Upcoming. groupAction: { color: '#2E4B35', fontSize: 20, fontFamily: 'Caveat', flex: 1, }, // The count · due (e.g. "2 plants · Today") — matches Upcoming's meta. // flex:1 pins the chevron to a consistent right edge across all rows so the // down-chevrons line up vertically regardless of how long each label is // (Bekky, 2026-08-23). groupMeta: { color: '#5D4E2B', fontSize: 17, fontFamily: 'Caveat', marginRight: 8, flex: 1, }, groupChevron: { color: '#2E6B3F', fontSize: 18, fontFamily: 'Caveat', }, groupWaterAllRow: { flexDirection: 'row', gap: 8, marginBottom: 6, marginTop: 2, }, groupWaterAll: { backgroundColor: '#F3F7E6', borderRadius: 999, alignItems: 'center', justifyContent: 'center', paddingVertical: 8, paddingHorizontal: 14, borderWidth: 1, borderColor: green, }, groupWaterAllText: { color: green, fontSize: 13, fontWeight: '800', }, groupBody: { paddingTop: 4, }, // Upcoming plant row right-side stack: space name on top, View Care/Case // button beneath. Left (plant name) centers against the middle of this stack // so rows stay compact (Bekky, 2026-08-22). upcomingRightStack: { alignItems: 'flex-end', gap: 4, marginLeft: 6, }, // Cohort group (Chunk 2, 2026-08-27) — sweepable unit in the Care tab. // JOURNAL STYLE (Bekky, 2026-08-29): NO green box/card. The group reads as a // handwritten journal entry — Caveat script, wispy underline, parchment, no // italic-serif fallback. Looks like the rest of the journal, not a button. cohortWrap: { marginBottom: 6, }, cohortHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 6, paddingHorizontal: 2, }, // The chevron is confined to its own corner touch target (Bekky, 2026-09-01): // it's the ONLY expand/collapse control; the group name opens the members // modal instead. A generous hit area so it's easy to tap. cohortChevronTouch: { paddingVertical: 8, paddingHorizontal: 10, marginRight: -6, alignItems: 'center', justifyContent: 'center', }, // Cohort members modal (Bekky, 2026-09-01) — tap the group name to see the // exact list of plants as clickable tiles. Matches the app's modal look. cohortMembersOverlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.55)', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20, }, cohortMembersCard: { width: '100%', maxHeight: '82%', backgroundColor: '#FFFDF4', borderRadius: 20, borderWidth: 1, borderColor: line, padding: 20, shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 8, }, cohortMembersTitle: { fontSize: 20, fontWeight: '900', color: dark, textAlign: 'center', fontFamily: 'Caveat', lineHeight: 24, }, cohortMembersSubtitle: { fontSize: 13, fontWeight: '700', color: '#8A8571', textAlign: 'center', marginTop: 2, marginBottom: 12, }, cohortMembersScroll: { flexGrow: 0, }, cohortMembersGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-start', }, cohortMemberTile: { width: '23%', minWidth: 64, // MATCH the space-tab Care Groups tile EXACTLY (Bekky, 2026-09-02): NO tile // outline/background — just the photo frame (which carries the border) + // the name below. Mirrors ProfileScreen spaceGroupTile (23% wide, no border, // alignItems center) + spaceGroupTileFrame (56px square, bordered). alignItems: 'center', }, // Photo frame — small 56px square like the space-tab (Bekky, 2026-09-02: // "make them match the space tab — we made them really small so they didn't // take up much space"). The border lives HERE, not on the tile. cohortMemberTilePhotoFrame: { width: 56, height: 56, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(191,217,180,0.95)', backgroundColor: '#FFFDF6', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0, }, cohortMemberTilePhoto: { width: '100%', height: '100%', }, cohortMemberTilePhotoFallback: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', backgroundColor: '#E8F5D3', }, cohortMemberTileIcon: { fontSize: 20, lineHeight: 24, }, // Name copy — centered below the photo, like gardenTileCopy (flex:1 center). cohortMemberTileCopy: { flex: 1, width: '100%', minWidth: 0, justifyContent: 'center', alignItems: 'center', marginTop: 4, }, cohortMemberTileName: { // Matches space-tab spaceGroupTileName (Bekky, 2026-09-02): #2E4B35, 10px, // 700 weight, centered, small margin under the photo. color: '#2E4B35', fontSize: 10, lineHeight: 13, fontWeight: '700', marginTop: 3, textAlign: 'center', flexShrink: 1, }, // Modal footer actions (Bekky, 2026-09-01): Complete all + Done side by side. cohortMembersActions: { flexDirection: 'row', gap: 10, marginTop: 16, }, cohortMembersActionBtn: { flex: 1, borderRadius: 999, alignItems: 'center', justifyContent: 'center', paddingVertical: 12, paddingHorizontal: 8, }, cohortMembersComplete: { backgroundColor: green, }, cohortMembersCompleteText: { color: '#FFFDF4', fontSize: 15, fontWeight: '800', }, cohortMembersDone: { backgroundColor: '#EAF0DC', borderWidth: 1, borderColor: green, }, cohortMembersDoneText: { color: '#2E6B3F', fontSize: 15, fontWeight: '800', }, // Enlarged member photo (Bekky, 2026-09-02). cohortEnlargeOverlay: { flex: 1, backgroundColor: 'rgba(10, 20, 10, 0.9)', justifyContent: 'center', alignItems: 'center', padding: 24, }, cohortEnlargeScrim: { ...StyleSheet.absoluteFillObject, }, cohortEnlargeImg: { width: '100%', height: '75%', borderRadius: 16, }, cohortEnlargeClose: { marginTop: 20, minHeight: 44, paddingHorizontal: 28, borderRadius: 999, backgroundColor: 'rgba(255,255,255,0.16)', alignItems: 'center', justifyContent: 'center', }, cohortEnlargeCloseText: { color: '#FFF', fontSize: 15, fontWeight: '700', }, cohortIcon: { fontSize: 20, lineHeight: 24, }, cohortCopy: { flex: 1, minWidth: 0, }, cohortTitle: { color: dark, fontSize: 20, fontFamily: 'Caveat', lineHeight: 23, }, cohortMeta: { color: '#5D4E2B', fontSize: 17, fontFamily: 'Caveat', lineHeight: 19, }, // Wispy rule under the group header — a hint of separation, not a box. cohortHeaderRule: { height: 1, backgroundColor: 'rgba(46,107,63,0.28)', marginBottom: 4, marginHorizontal: 2, }, cohortNote: { color: '#5B7553', fontSize: 17, fontFamily: 'Caveat', lineHeight: 20, paddingVertical: 2, paddingHorizontal: 4, }, cohortSoilCheckNote: { color: '#8A5A2B', fontSize: 16, fontFamily: 'Caveat', lineHeight: 19, paddingVertical: 2, paddingHorizontal: 4, fontStyle: 'normal', }, cohortPlantRow: { color: dark, fontSize: 18, fontFamily: 'Caveat', lineHeight: 21, paddingVertical: 1, paddingHorizontal: 4, // NO flex on this Text — a `flex: 1` here (flexBasis 0%) collapsed rows to // ~5px slits on-device (2026-08-29 root cause: rendered-but-collapsed rows // inside the expanded cohort group; verified via uiautomator bounds). // Height comes from lineHeight; width limiting from the parent // cohortPlantRowCopy wrapper (minWidth: 0). }, // Per-plant delta copy wrapper (Chunk A, Bekky 2026-08-29): name on top, the // AI-reasoning sentence beneath it. cohortPlantRowCopy: { flex: 1, minWidth: 0, }, // Per-plant AI-reasoning delta (Chunk A, Bekky 2026-08-29): a simple sentence // explaining why this plant's care is slightly different from the group. // Rendered in Caveat, NOT italic (italic on Caveat falls back to a serif). cohortPlantDelta: { color: '#78694B', fontSize: 16, fontFamily: 'Caveat', paddingHorizontal: 4, paddingBottom: 2, lineHeight: 18, }, // A member held out this round (Bekky, 2026-08-29): a recent manual water put // it off-schedule, so it stays in the group but is grayed out with a note. cohortPlantRowSkipped: { opacity: 0.45, }, // Complete-all button inside an expanded group (Chunk A, Bekky 2026-08-29): // the group is the unit of care — one tap completes all members. Styled as a // gentle journal action line, not a heavy pill. cohortCompleteAll: { alignSelf: 'flex-start', paddingVertical: 4, paddingHorizontal: 6, borderBottomWidth: 1, borderBottomColor: 'rgba(46,107,63,0.5)', marginTop: 6, marginLeft: 4, }, cohortCompleteAllText: { color: '#2E6B3F', fontSize: 17, fontFamily: 'Caveat', lineHeight: 20, }, // Tappable plant row inside an expanded cohort (Bekky, 2026-08-29): each plant // opens its rich check-in modal. Chevron on the right signals it's tappable. cohortPlantRowTouch: { flexDirection: 'row', alignItems: 'center', paddingVertical: 2, paddingHorizontal: 4, }, cohortPlantChevron: { color: '#2E6B3F', fontSize: 18, fontFamily: 'Caveat', marginLeft: 6, }, cohortModalFeedbackRow: { flexDirection: 'row', gap: 8, marginTop: 14, }, cohortModalFeedback: { flex: 1, borderRadius: 999, alignItems: 'center', justifyContent: 'center', paddingVertical: 10, borderWidth: 1, }, cohortModalFeedbackDry: { backgroundColor: '#FEF3E2', borderColor: '#E8A13C', }, cohortModalFeedbackWet: { backgroundColor: '#E8F0FA', borderColor: '#4A90C4', }, // Fertilize feedback colors (Bekky, 2026-08-29): orange (pushing growth) + // green (not growing) — keeps the green/blue scheme in mind but gives the // fertilize group its own distinct pair. cohortModalFeedbackFertDry: { backgroundColor: '#FEF3E2', borderColor: '#E8A13C', }, cohortModalFeedbackFertWet: { backgroundColor: '#E8F5D3', borderColor: '#7FBE63', }, cohortModalFeedbackText: { fontSize: 12, fontWeight: '800', color: dark, textAlign: 'center', }, });