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