feat(add): collapse the capture form into a title card plus an attribute bar

This commit is contained in:
2026-09-01 22:53:17 +10:00
parent 5638aa9030
commit e299ab69bd
2 changed files with 237 additions and 86 deletions

View File

@@ -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<Task["priority"] | null>(null);
const [estimate, setEstimate] = useState<number | null>(null);
const [due, setDue] = useState("");
const [due, setDue] = useState<string | null>(null);
const [repeat, setRepeat] = useState<Repeat | null>(null);
const [box, setBox] = useState<TaskStatus>("next");
const [box, setBox] = useState<Box>("next");
const [waitingFor, setWaitingFor] = useState("");
const [projectId, setProjectId] = useState<string | null>(null);
const [context, setContext] = useState<string | null>(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<ParsedQuickAdd>({ 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,43 +91,38 @@ export default function AddTask() {
router.back();
};
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Field
label="Task"
value={raw}
onChangeText={onTitleChange}
placeholder="Pay rent tmr p1 @home ~30m"
autoFocus
/>
{tokensUsed && (
<Text style={s.parsedHint}>
→ {cleanTitle || "…"}
{context ? ` · ${context}` : ""}
</Text>
)}
<PriorityChips value={priority} onChange={setPriority} />
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
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: <DateField value={due} onChange={setDue} clearable />,
onClear: () => setDue(null),
},
{
key: "priority",
icon: "flag",
placeholder: "Priority",
value: priority ? PRIORITY_LABEL[priority] : null,
tone: priority ? PRIORITY_COLOR[priority] : undefined,
panel: <PriorityChips value={priority} onChange={setPriority} />,
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: (
<View>
<Chips
label="Repeat"
options={["none", ...REPEATS]}
labels={{ none: "None", ...REPEAT_LABEL }}
value={repeat ?? "none"}
onChange={(v) => {
haptic.select();
setRepeat(v === "none" ? null : (v as Repeat));
}}
/>
<EstimateChips value={estimate} onChange={setEstimate} />
<Chips
label="Box"
options={BOXES.map((b) => b.key)}
labels={Object.fromEntries(BOXES.map((b) => [b.key, b.label]))}
options={BOXES}
labels={BOX_LABEL}
value={box}
onChange={(b) => {
haptic.select();
@@ -124,30 +130,132 @@ export default function AddTask() {
}}
/>
{box === "waiting" && (
<Field label="Waiting for" value={waitingFor} onChangeText={setWaitingFor} placeholder="Who or what?" />
)}
<Chips
label="Project"
options={projectOptions}
labels={projectLabels}
value={projectId ?? "none"}
onChange={(v) => setProjectId(v === "none" ? null : v)}
<TextInput
style={s.panelInput}
value={waitingFor}
onChangeText={setWaitingFor}
placeholder="Waiting for who or what?"
placeholderTextColor={C.muted}
/>
<Field
label="Description"
)}
</View>
),
},
{
key: "project",
icon: "projects",
placeholder: "Project",
value: projectName,
panel:
projects.length === 0 ? (
<Text style={s.panelEmpty}>No active projects yet.</Text>
) : (
<Chips
options={projects.map((p) => 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: <EstimateChips value={estimate} onChange={setEstimate} />,
onClear: () => setEstimate(null),
},
{
key: "repeat",
icon: "repeat",
placeholder: "Repeat",
value: repeat ? REPEAT_LABEL[repeat] : null,
panel: (
<Chips
options={REPEATS}
labels={REPEAT_LABEL}
value={repeat}
onChange={(v) => {
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: (
<Chips
options={contexts.includes(context ?? "") || !context ? contexts : [...contexts, context]}
value={context}
onChange={(v) => {
haptic.select();
setContext(v === context ? null : v);
}}
/>
),
onClear: () => setContext(null),
});
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={s.card}>
<TextInput
style={s.titleInput}
value={raw}
onChangeText={onTitleChange}
placeholder="New to-do"
placeholderTextColor={C.muted}
autoFocus
returnKeyType="done"
onSubmitEditing={save}
/>
<Separator />
<TextInput
style={s.notesInput}
value={notes}
onChangeText={setNotes}
placeholder="Details, links, notes…"
placeholder="Notes"
placeholderTextColor={C.muted}
multiline
style={{ minHeight: 88, textAlignVertical: "top" }}
/>
</View>
{tokensUsed ? (
<Text style={s.hint}>Saving as “{cleanTitle || "…"}” — tokens moved into the chips below.</Text>
) : (
<Text style={s.hint}>Type p1 · tmr · @home · ~30m · #project to fill these in as you go.</Text>
)}
<AttrBar attrs={attrs} />
<Pressable
style={({ pressed }) => [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}
>
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>
{ready ? "Add task" : "Needs a title, priority & due date"}
Add to {BOX_LABEL[box]}
</Text>
</Pressable>
</ScrollView>
@@ -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 },

View File

@@ -142,6 +142,13 @@ export default function DateField({
setOpen(!open);
}}
/>
{/* Standalone only — inside an AttrBar panel the header owns "Clear". */}
{clearable && !!label && value != null && (
<OptionChip label="Clear" onPress={() => {
haptic.tap();
onChange(null);
}} />
)}
</ChipRow>
{open && (
<Calendar