From e299ab69bd1af247a73ce455c47f468a35afec10 Mon Sep 17 00:00:00 2001 From: Marcusrehbock Date: Tue, 1 Sep 2026 22:53:17 +1000 Subject: [PATCH] feat(add): collapse the capture form into a title card plus an attribute bar --- app/src/app/add.tsx | 316 ++++++++++++++++++++++--------- app/src/components/DateField.tsx | 7 + 2 files changed, 237 insertions(+), 86 deletions(-) diff --git a/app/src/app/add.tsx b/app/src/app/add.tsx index 346c387..8a29e41 100644 --- a/app/src/app/add.tsx +++ b/app/src/app/add.tsx @@ -1,24 +1,37 @@ import { useRouter } from "expo-router"; import { useRef, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text } from "react-native"; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; +import AttrBar, { type Attr } from "../components/AttrBar"; import DateField from "../components/DateField"; import EstimateChips from "../components/EstimateChips"; import PriorityChips from "../components/PriorityChips"; -import { Chips, Field } from "../components/ui"; +import { Chips, Separator } from "../components/ui"; +import { friendlyDate } from "../lib/dates"; import { haptic } from "../lib/haptics"; +import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd"; import { REPEAT_LABEL, REPEATS } from "../lib/repeat"; -import { activeProjects, createTask } from "../lib/store"; +import { activeProjects, createTask, knownContexts } from "../lib/store"; import { C, radius, space, type } from "../lib/theme"; import { useStore } from "../lib/useStore"; -import type { Repeat, Task, TaskStatus } from "../lib/types"; +import type { Repeat, Task } from "../lib/types"; -const BOXES: { key: TaskStatus; label: string }[] = [ - { key: "next", label: "Next up" }, - { key: "waiting", label: "Waiting" }, - { key: "someday", label: "Someday" }, -]; +const BOX_LABEL = { + next: "Next up", + waiting: "Waiting", + someday: "Someday", +} as const; +type Box = keyof typeof BOX_LABEL; +const BOXES = Object.keys(BOX_LABEL) as Box[]; +/** + * Capture screen. + * + * Deliberately one card and one row of chips. Everything optional lives behind + * a chip that shows its own value, so the common case — type a title, hit Add — + * is two interactions and never scrolls, while a fully specified task is still + * reachable without leaving the screen. + */ export default function AddTask() { const router = useRouter(); const store = useStore(); @@ -26,21 +39,20 @@ export default function AddTask() { const [notes, setNotes] = useState(""); const [priority, setPriority] = useState(null); const [estimate, setEstimate] = useState(null); - const [due, setDue] = useState(""); + const [due, setDue] = useState(null); const [repeat, setRepeat] = useState(null); - const [box, setBox] = useState("next"); + const [box, setBox] = useState("next"); const [waitingFor, setWaitingFor] = useState(""); const [projectId, setProjectId] = useState(null); const [context, setContext] = useState(null); const projects = activeProjects(store).filter((p) => p.status === "active"); - const projectOptions = ["none", ...projects.map((p) => p.id)]; - const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); + const contexts = knownContexts(store); // The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly") - // and mirrors each one into the form controls below as you type, so capture - // is one line but every inference stays visible and correctable. A token - // only writes its field when it changes — chips tapped by hand stick. + // and mirrors each one into the chips above as you type, so capture is one + // line but every inference stays visible and correctable. A token only + // writes its field when it changes — chips tapped by hand stick. const prevParsed = useRef({ title: "" }); const onTitleChange = (text: string) => { setRaw(text); @@ -57,20 +69,19 @@ export default function AddTask() { const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim(); const tokensUsed = raw.trim() !== cleanTitle; - - // Every task must be born fully scoped: title + priority + due date. - const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim()); - const ready = cleanTitle.length > 0 && priority !== null && dueValid; + const ready = cleanTitle.length > 0; const save = () => { - if (!ready || priority === null) return; + if (!ready) return; createTask({ title: cleanTitle, notes, status: box, - priority, + // Unset priority means "ordinary", not "urgent" — pick the middle rung + // rather than blocking capture on a decision that can wait for review. + priority: priority ?? 3, estimate_min: estimate, - due_date: due.trim(), + due_date: due, repeat, project_id: projectId, context, @@ -80,6 +91,127 @@ export default function AddTask() { router.back(); }; + const projectName = projects.find((p) => p.id === projectId)?.name ?? null; + + const attrs: Attr[] = [ + { + key: "when", + icon: "today", + placeholder: "When", + value: due ? friendlyDate(due) : null, + panel: , + onClear: () => setDue(null), + }, + { + key: "priority", + icon: "flag", + placeholder: "Priority", + value: priority ? PRIORITY_LABEL[priority] : null, + tone: priority ? PRIORITY_COLOR[priority] : undefined, + panel: , + onClear: () => setPriority(null), + }, + { + key: "box", + icon: "next", + // "Next up" is the default, so it reads as a placeholder until changed — + // only a deliberate choice earns the accent. + placeholder: BOX_LABEL.next, + value: box === "next" ? null : BOX_LABEL[box], + panel: ( + + { + haptic.select(); + setBox(b); + }} + /> + {box === "waiting" && ( + + )} + + ), + }, + { + key: "project", + icon: "projects", + placeholder: "Project", + value: projectName, + panel: + projects.length === 0 ? ( + No active projects yet. + ) : ( + p.id)} + labels={Object.fromEntries(projects.map((p) => [p.id, p.name]))} + value={projectId} + onChange={(v) => { + haptic.select(); + setProjectId(v === projectId ? null : v); + }} + /> + ), + onClear: () => setProjectId(null), + }, + { + key: "estimate", + icon: "waiting", + placeholder: "Estimate", + value: formatEstimate(estimate), + panel: , + onClear: () => setEstimate(null), + }, + { + key: "repeat", + icon: "repeat", + placeholder: "Repeat", + value: repeat ? REPEAT_LABEL[repeat] : null, + panel: ( + { + haptic.select(); + setRepeat(v === repeat ? null : v); + }} + /> + ), + onClear: () => setRepeat(null), + }, + ]; + + // Only offer the tag picker once there's something to pick — an empty chip + // that opens an empty panel is worse than no chip. + if (contexts.length > 0 || context) { + attrs.push({ + key: "context", + icon: "tag", + placeholder: "Tag", + value: context, + panel: ( + { + haptic.select(); + setContext(v === context ? null : v); + }} + /> + ), + onClear: () => setContext(null), + }); + } + return ( - - {tokensUsed && ( - - → {cleanTitle || "…"} - {context ? ` · ${context}` : ""} - + + + + + + + {tokensUsed ? ( + Saving as “{cleanTitle || "…"}” — tokens moved into the chips below. + ) : ( + Type p1 · tmr · @home · ~30m · #project to fill these in as you go. )} - - setDue(v ?? "")} /> - { - haptic.select(); - setRepeat(v === "none" ? null : (v as Repeat)); - }} - /> - - b.key)} - labels={Object.fromEntries(BOXES.map((b) => [b.key, b.label]))} - value={box} - onChange={(b) => { - haptic.select(); - setBox(b); - }} - /> - {box === "waiting" && ( - - )} - setProjectId(v === "none" ? null : v)} - /> - + + + [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.8 }]} + style={({ pressed }) => [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.85 }]} onPress={save} disabled={!ready} > - {ready ? "Add task" : "Needs a title, priority & due date"} + Add to {BOX_LABEL[box]} @@ -156,20 +264,56 @@ export default function AddTask() { const s = StyleSheet.create({ content: { padding: space.lg, paddingBottom: space.xxl }, - parsedHint: { - ...type.meta, - color: C.accent, - marginTop: -space.md, - marginBottom: space.md, - fontWeight: "500", + + card: { + backgroundColor: C.surface, + borderRadius: radius.lg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: C.border, + overflow: "hidden", }, + titleInput: { + color: C.text, + fontSize: 20, + fontWeight: "600", + letterSpacing: -0.3, + paddingHorizontal: space.lg, + paddingVertical: space.lg - 2, + }, + notesInput: { + color: C.text, + fontSize: 15, + minHeight: 76, + textAlignVertical: "top", + paddingHorizontal: space.lg, + paddingVertical: space.md, + }, + + hint: { + ...type.meta, + color: C.muted, + paddingHorizontal: space.xs, + paddingTop: space.md, + paddingBottom: space.md, + }, + + panelInput: { + backgroundColor: C.surface2, + color: C.text, + borderRadius: radius.md, + paddingHorizontal: space.md, + paddingVertical: space.md - 2, + fontSize: 15, + marginTop: space.md, + }, + panelEmpty: { ...type.meta, color: C.muted, paddingBottom: space.sm }, + save: { backgroundColor: C.accent, borderRadius: radius.md, paddingVertical: space.lg - 2, alignItems: "center", - marginTop: space.sm, - marginBottom: space.xxl, + marginTop: space.xl, }, saveDisabled: { backgroundColor: C.surface2 }, saveText: { fontSize: 16, fontWeight: "600", color: C.onAccent }, diff --git a/app/src/components/DateField.tsx b/app/src/components/DateField.tsx index b8f0fdd..38ca51f 100644 --- a/app/src/components/DateField.tsx +++ b/app/src/components/DateField.tsx @@ -142,6 +142,13 @@ export default function DateField({ setOpen(!open); }} /> + {/* Standalone only — inside an AttrBar panel the header owns "Clear". */} + {clearable && !!label && value != null && ( + { + haptic.tap(); + onChange(null); + }} /> + )} {open && (