feat(add): collapse the capture form into a title card plus an attribute bar
This commit is contained in:
@@ -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,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: <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
|
||||
options={BOXES}
|
||||
labels={BOX_LABEL}
|
||||
value={box}
|
||||
onChange={(b) => {
|
||||
haptic.select();
|
||||
setBox(b);
|
||||
}}
|
||||
/>
|
||||
{box === "waiting" && (
|
||||
<TextInput
|
||||
style={s.panelInput}
|
||||
value={waitingFor}
|
||||
onChangeText={setWaitingFor}
|
||||
placeholder="Waiting for who or what?"
|
||||
placeholderTextColor={C.muted}
|
||||
/>
|
||||
)}
|
||||
</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 }}
|
||||
@@ -87,67 +219,43 @@ export default function AddTask() {
|
||||
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>
|
||||
<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="Notes"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<PriorityChips value={priority} onChange={setPriority} />
|
||||
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
|
||||
<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]))}
|
||||
value={box}
|
||||
onChange={(b) => {
|
||||
haptic.select();
|
||||
setBox(b);
|
||||
}}
|
||||
/>
|
||||
{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)}
|
||||
/>
|
||||
<Field
|
||||
label="Description"
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Details, links, notes…"
|
||||
multiline
|
||||
style={{ minHeight: 88, textAlignVertical: "top" }}
|
||||
/>
|
||||
|
||||
<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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user