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 { useRouter } from "expo-router";
|
||||||
import { useRef, useState } from "react";
|
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 DateField from "../components/DateField";
|
||||||
import EstimateChips from "../components/EstimateChips";
|
import EstimateChips from "../components/EstimateChips";
|
||||||
import PriorityChips from "../components/PriorityChips";
|
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 { haptic } from "../lib/haptics";
|
||||||
|
import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
|
||||||
import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
|
import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
|
||||||
import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
|
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 { C, radius, space, type } from "../lib/theme";
|
||||||
import { useStore } from "../lib/useStore";
|
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 }[] = [
|
const BOX_LABEL = {
|
||||||
{ key: "next", label: "Next up" },
|
next: "Next up",
|
||||||
{ key: "waiting", label: "Waiting" },
|
waiting: "Waiting",
|
||||||
{ key: "someday", label: "Someday" },
|
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() {
|
export default function AddTask() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -26,21 +39,20 @@ export default function AddTask() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [priority, setPriority] = useState<Task["priority"] | null>(null);
|
const [priority, setPriority] = useState<Task["priority"] | null>(null);
|
||||||
const [estimate, setEstimate] = useState<number | 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 [repeat, setRepeat] = useState<Repeat | null>(null);
|
||||||
const [box, setBox] = useState<TaskStatus>("next");
|
const [box, setBox] = useState<Box>("next");
|
||||||
const [waitingFor, setWaitingFor] = useState("");
|
const [waitingFor, setWaitingFor] = useState("");
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
const [context, setContext] = useState<string | null>(null);
|
const [context, setContext] = useState<string | null>(null);
|
||||||
|
|
||||||
const projects = activeProjects(store).filter((p) => p.status === "active");
|
const projects = activeProjects(store).filter((p) => p.status === "active");
|
||||||
const projectOptions = ["none", ...projects.map((p) => p.id)];
|
const contexts = knownContexts(store);
|
||||||
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
|
|
||||||
|
|
||||||
// The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
|
// 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
|
// and mirrors each one into the chips above as you type, so capture is one
|
||||||
// is one line but every inference stays visible and correctable. A token
|
// line but every inference stays visible and correctable. A token only
|
||||||
// only writes its field when it changes — chips tapped by hand stick.
|
// writes its field when it changes — chips tapped by hand stick.
|
||||||
const prevParsed = useRef<ParsedQuickAdd>({ title: "" });
|
const prevParsed = useRef<ParsedQuickAdd>({ title: "" });
|
||||||
const onTitleChange = (text: string) => {
|
const onTitleChange = (text: string) => {
|
||||||
setRaw(text);
|
setRaw(text);
|
||||||
@@ -57,20 +69,19 @@ export default function AddTask() {
|
|||||||
|
|
||||||
const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
|
const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
|
||||||
const tokensUsed = raw.trim() !== cleanTitle;
|
const tokensUsed = raw.trim() !== cleanTitle;
|
||||||
|
const ready = cleanTitle.length > 0;
|
||||||
// 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 save = () => {
|
const save = () => {
|
||||||
if (!ready || priority === null) return;
|
if (!ready) return;
|
||||||
createTask({
|
createTask({
|
||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
notes,
|
notes,
|
||||||
status: box,
|
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,
|
estimate_min: estimate,
|
||||||
due_date: due.trim(),
|
due_date: due,
|
||||||
repeat,
|
repeat,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
context,
|
context,
|
||||||
@@ -80,6 +91,127 @@ export default function AddTask() {
|
|||||||
router.back();
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1, backgroundColor: C.bg }}
|
style={{ flex: 1, backgroundColor: C.bg }}
|
||||||
@@ -87,67 +219,43 @@ export default function AddTask() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Field
|
<View style={s.card}>
|
||||||
label="Task"
|
<TextInput
|
||||||
value={raw}
|
style={s.titleInput}
|
||||||
onChangeText={onTitleChange}
|
value={raw}
|
||||||
placeholder="Pay rent tmr p1 @home ~30m"
|
onChangeText={onTitleChange}
|
||||||
autoFocus
|
placeholder="New to-do"
|
||||||
/>
|
placeholderTextColor={C.muted}
|
||||||
{tokensUsed && (
|
autoFocus
|
||||||
<Text style={s.parsedHint}>
|
returnKeyType="done"
|
||||||
→ {cleanTitle || "…"}
|
onSubmitEditing={save}
|
||||||
{context ? ` · ${context}` : ""}
|
/>
|
||||||
</Text>
|
<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 ?? "")} />
|
<AttrBar attrs={attrs} />
|
||||||
<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" }}
|
|
||||||
/>
|
|
||||||
<Pressable
|
<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}
|
onPress={save}
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
>
|
>
|
||||||
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>
|
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>
|
||||||
{ready ? "Add task" : "Needs a title, priority & due date"}
|
Add to {BOX_LABEL[box]}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -156,20 +264,56 @@ export default function AddTask() {
|
|||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
content: { padding: space.lg, paddingBottom: space.xxl },
|
content: { padding: space.lg, paddingBottom: space.xxl },
|
||||||
parsedHint: {
|
|
||||||
...type.meta,
|
card: {
|
||||||
color: C.accent,
|
backgroundColor: C.surface,
|
||||||
marginTop: -space.md,
|
borderRadius: radius.lg,
|
||||||
marginBottom: space.md,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
fontWeight: "500",
|
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: {
|
save: {
|
||||||
backgroundColor: C.accent,
|
backgroundColor: C.accent,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
paddingVertical: space.lg - 2,
|
paddingVertical: space.lg - 2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: space.sm,
|
marginTop: space.xl,
|
||||||
marginBottom: space.xxl,
|
|
||||||
},
|
},
|
||||||
saveDisabled: { backgroundColor: C.surface2 },
|
saveDisabled: { backgroundColor: C.surface2 },
|
||||||
saveText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
|
saveText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ export default function DateField({
|
|||||||
setOpen(!open);
|
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>
|
</ChipRow>
|
||||||
{open && (
|
{open && (
|
||||||
<Calendar
|
<Calendar
|
||||||
|
|||||||
Reference in New Issue
Block a user