diff --git a/app/src/components/AttrBar.tsx b/app/src/components/AttrBar.tsx new file mode 100644 index 0000000..408c51d --- /dev/null +++ b/app/src/components/AttrBar.tsx @@ -0,0 +1,126 @@ +// The attribute bar: one wrapping row of chips that doubles as the task's +// summary and as the entry point to every optional field. +// +// The old form stacked seven labelled control groups down the screen, so +// capturing "call Sam" meant scrolling past a calendar, a repeat picker and an +// estimate picker to reach the save button. Here each field collapses to a +// single chip: unset chips read as a quiet placeholder, set chips show their +// value in the accent. Exactly one panel is open at a time, so the screen +// never grows past roughly a phone height. +import { useState } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { haptic } from "../lib/haptics"; +import { C, radius, space, type } from "../lib/theme"; +import Icon, { type IconName } from "./Icon"; + +export type Attr = { + key: string; + icon: IconName; + /** Chip label while the field is unset. */ + placeholder: string; + /** Chip label once set — this is what makes the bar readable as a summary. */ + value?: string | null; + /** Chip accent when set. Defaults to the app accent. */ + tone?: string; + /** Revealed under the bar when the chip is tapped. */ + panel: React.ReactNode; + /** Offered as "Clear" in the open panel's header. Omit for required fields. */ + onClear?: () => void; +}; + +export default function AttrBar({ attrs }: { attrs: Attr[] }) { + const [open, setOpen] = useState(null); + const active = attrs.find((a) => a.key === open) ?? null; + + return ( + + + {attrs.map((a) => { + const set = !!a.value; + const tone = a.tone ?? C.accent; + const isOpen = a.key === open; + return ( + { + haptic.tap(); + setOpen(isOpen ? null : a.key); + }} + style={({ pressed }) => [ + s.chip, + set && { borderColor: tone, backgroundColor: `${tone}14` }, + isOpen && { borderColor: tone, backgroundColor: `${tone}22` }, + pressed && { opacity: 0.7 }, + ]} + > + + + {a.value || a.placeholder} + + + ); + })} + + + {!!active && ( + + + {active.placeholder} + {!!active.onClear && !!active.value && ( + { + haptic.select(); + active.onClear?.(); + }} + hitSlop={8} + style={({ pressed }) => pressed && { opacity: 0.6 }} + > + Clear + + )} + setOpen(null)} hitSlop={10} style={({ pressed }) => pressed && { opacity: 0.6 }}> + + + + {active.panel} + + )} + + ); +} + +const s = StyleSheet.create({ + row: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, + chip: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingVertical: space.sm - 1, + paddingLeft: space.md - 2, + paddingRight: space.lg - 2, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: C.border, + backgroundColor: C.surface, + }, + chipText: { ...type.chip, color: C.textSecondary }, + + panel: { + marginTop: space.md, + backgroundColor: C.surface, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: C.border, + paddingHorizontal: space.lg, + paddingBottom: space.md, + }, + panelHead: { + flexDirection: "row", + alignItems: "center", + gap: space.md, + paddingTop: space.md, + paddingBottom: space.xs, + }, + panelTitle: { ...type.section, color: C.textSecondary, flex: 1 }, + clear: { ...type.meta, color: C.accent, fontWeight: "600" }, +}); diff --git a/app/src/components/DateField.tsx b/app/src/components/DateField.tsx index b1ba458..b8f0fdd 100644 --- a/app/src/components/DateField.tsx +++ b/app/src/components/DateField.tsx @@ -3,8 +3,9 @@ import { useMemo, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { haptic } from "../lib/haptics"; +import { friendlyDate, fromIso, toIso as iso, todayIso } from "../lib/dates"; import { C, radius, space, type } from "../lib/theme"; -import { SectionHeader } from "./ui"; +import { ChipRow, OptionChip, SectionHeader } from "./ui"; const DAY_MS = 86_400_000; const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"]; @@ -13,31 +14,11 @@ const MONTHS = [ "July", "August", "September", "October", "November", "December", ]; -function iso(d: Date): string { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - -function fromIso(s: string): Date { - const [y, m, d] = s.split("-").map(Number); - return new Date(y, m - 1, d); -} - -function pretty(s: string): string { - const d = fromIso(s); - const today = new Date(); - const diff = Math.round((d.getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / DAY_MS); - if (diff === 0) return "Today"; - if (diff === 1) return "Tomorrow"; - if (diff === -1) return "Yesterday"; - const wd = d.toLocaleDateString("en-AU", { weekday: "short" }); - return `${wd} ${d.getDate()} ${MONTHS[d.getMonth()].slice(0, 3)}${d.getFullYear() !== today.getFullYear() ? ` ${d.getFullYear()}` : ""}`; -} - function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) => void }) { const initial = value ? fromIso(value) : new Date(); const [year, setYear] = useState(initial.getFullYear()); const [month, setMonth] = useState(initial.getMonth()); - const todayIso = iso(new Date()); + const today = todayIso(); const weeks = useMemo(() => { const first = new Date(year, month, 1); @@ -85,7 +66,7 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) const dIso = iso(d); const inMonth = d.getMonth() === month; const selected = dIso === value; - const isToday = dIso === todayIso; + const isToday = dIso === today; return ( void; clearable?: boolean; @@ -134,45 +115,34 @@ export default function DateField({ ]; return ( - - - {label} - {value != null && {pretty(value)}} - - + + {!!label && ( + + {label} + {value != null && {friendlyDate(value)}} + + )} + {quick.map((q) => ( - { haptic.select(); onChange(value === q.v && clearable ? null : q.v); }} - > - {q.label} - + /> ))} - q.v === value) ? friendlyDate(value) : "Pick a date…"} + active={open} onPress={() => { haptic.select(); setOpen(!open); }} - > - Pick a date… - - {clearable && value != null && ( - { - haptic.tap(); - onChange(null); - }} - > - Clear - - )} - + /> + {open && ( void; + label?: string; }) { const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS; return ( - - Estimated time - - {options.map((m) => { - const active = value === m; - return ( - [ - s.chip, - active && s.chipActive, - pressed && !active && { backgroundColor: C.surface2 }, - ]} - onPress={() => { - haptic.select(); - onChange(active ? null : m); - }} - > - {formatEstimate(m)} - - ); - })} - + + {!!label && {label}} + + {options.map((m) => ( + { + haptic.select(); + onChange(value === m ? null : m); + }} + /> + ))} + ); } - -const s = StyleSheet.create({ - wrap: { marginBottom: space.lg }, - rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, - chip: { - paddingVertical: 9, - paddingHorizontal: 15, - borderRadius: radius.pill, - backgroundColor: C.surface, - borderWidth: 1, - borderColor: C.border, - }, - chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent }, - chipText: { ...type.chip, color: C.textSecondary }, - chipTextActive: { color: C.accent, fontWeight: "600" }, -}); diff --git a/app/src/components/PriorityChips.tsx b/app/src/components/PriorityChips.tsx index a57c7e2..e130ae0 100644 --- a/app/src/components/PriorityChips.tsx +++ b/app/src/components/PriorityChips.tsx @@ -1,63 +1,43 @@ -import { Pressable, StyleSheet, Text, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { haptic } from "../lib/haptics"; import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority"; -import { C, radius, space, type } from "../lib/theme"; +import { C, space } from "../lib/theme"; import type { Task } from "../lib/types"; -import { SectionHeader } from "./ui"; +import { ChipRow, OptionChip, SectionHeader } from "./ui"; +/** Omit `label` when the surrounding panel already titles the group. */ export default function PriorityChips({ value, onChange, + label, }: { value: Task["priority"] | null; onChange: (p: Task["priority"]) => void; + label?: string; }) { return ( - - Priority - - {PRIORITIES.map((p) => { - const active = value === p; - const color = PRIORITY_COLOR[p]; - return ( - [ - s.chip, - active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] }, - pressed && !active && { backgroundColor: C.surface2 }, - ]} - onPress={() => { - haptic.select(); - onChange(p); - }} - > - - - {PRIORITY_LABEL[p]} - - - ); - })} - + + {!!label && {label}} + + {PRIORITIES.map((p) => ( + } + onPress={() => { + haptic.select(); + onChange(p); + }} + /> + ))} + ); } const s = StyleSheet.create({ - wrap: { marginBottom: space.lg }, - rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, - chip: { - flexDirection: "row", - alignItems: "center", - gap: 7, - paddingVertical: 9, - paddingHorizontal: 14, - borderRadius: radius.pill, - backgroundColor: C.surface, - borderWidth: 1, - borderColor: C.border, - }, - flag: { width: 9, height: 9, borderRadius: 999 }, - text: { ...type.chip, color: C.textSecondary }, + flag: { width: 9, height: 9, borderRadius: 999, backgroundColor: C.muted }, }); diff --git a/app/src/components/ui.tsx b/app/src/components/ui.tsx index b3860cd..c3f3c26 100644 --- a/app/src/components/ui.tsx +++ b/app/src/components/ui.tsx @@ -66,6 +66,47 @@ export function Field({ label, style, ...props }: TextInputProps & { label: stri ); } +/** One wrapping row of option chips. The single source of chip geometry. */ +export function ChipRow({ children }: { children: React.ReactNode }) { + return {children}; +} + +/** + * A single selectable chip. `tone` colours the selected border/text and `tint` + * its fill — priority chips pass their flag colour, everything else falls back + * to the app accent. + */ +export function OptionChip({ + label, + active, + onPress, + tone = C.accent, + tint = C.accentSoft, + leading, +}: { + label: string; + active?: boolean; + onPress: () => void; + tone?: string; + tint?: string; + leading?: React.ReactNode; +}) { + return ( + [ + u.chip, + active && { borderColor: tone, backgroundColor: tint }, + pressed && !active && u.chipPressed, + ]} + onPress={onPress} + > + {leading} + {label} + + ); +} + +/** Labelled chip group. Omit `label` when the surrounding panel already titles it. */ export function Chips({ label, options, @@ -73,29 +114,25 @@ export function Chips({ onChange, labels, }: { - label: string; + label?: string; options: T[]; value: T | null; onChange: (v: T) => void; labels?: Record; }) { return ( - - {label} - - {options.map((o) => { - const active = value === o; - return ( - [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]} - onPress={() => onChange(o)} - > - {labels?.[o] ?? o} - - ); - })} - + + {!!label && {label}} + + {options.map((o) => ( + onChange(o)} + /> + ))} + ); } @@ -183,17 +220,18 @@ const u = StyleSheet.create({ chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, chip: { - paddingVertical: space.sm, - paddingHorizontal: space.lg - 2, + flexDirection: "row", + alignItems: "center", + gap: 7, + paddingVertical: 9, + paddingHorizontal: 15, borderRadius: radius.pill, backgroundColor: C.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: C.border, }, - chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent }, chipPressed: { backgroundColor: C.surface2 }, chipText: { ...type.chip, color: C.textSecondary }, - chipTextActive: { color: C.accent, fontWeight: "600" }, button: { backgroundColor: C.accent,