refactor(task): one TaskForm behind both capture and clarify
This commit is contained in:
@@ -1,28 +1,12 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useRef, useState } from "react";
|
||||
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, Separator } from "../components/ui";
|
||||
import { friendlyDate } from "../lib/dates";
|
||||
import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
|
||||
import TaskForm, { BOX_LABEL, emptyDraft, type TaskDraft } from "../components/TaskForm";
|
||||
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, knownContexts } from "../lib/store";
|
||||
import { C, radius, space, type } from "../lib/theme";
|
||||
import { activeProjects, createTask } from "../lib/store";
|
||||
import { C, radius, space } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
import type { Repeat, Task } from "../lib/types";
|
||||
|
||||
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.
|
||||
@@ -35,183 +19,50 @@ const BOXES = Object.keys(BOX_LABEL) as Box[];
|
||||
export default function AddTask() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const [raw, setRaw] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [priority, setPriority] = useState<Task["priority"] | null>(null);
|
||||
const [estimate, setEstimate] = useState<number | null>(null);
|
||||
const [due, setDue] = useState<string | null>(null);
|
||||
const [repeat, setRepeat] = useState<Repeat | null>(null);
|
||||
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 [draft, setDraft] = useState<TaskDraft>(emptyDraft);
|
||||
|
||||
const projects = activeProjects(store).filter((p) => p.status === "active");
|
||||
const contexts = knownContexts(store);
|
||||
|
||||
// The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
|
||||
// and mirrors each one into the chips above as you type, so capture is one
|
||||
// and mirrors each one into the chips 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.
|
||||
const prevParsed = useRef<ParsedQuickAdd>({ title: "" });
|
||||
const onTitleChange = (text: string) => {
|
||||
setRaw(text);
|
||||
const p = parseQuickAdd(text, projects);
|
||||
const change = (patch: Partial<TaskDraft>) => {
|
||||
if (patch.title === undefined) return setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const p = parseQuickAdd(patch.title, projects);
|
||||
const prev = prevParsed.current;
|
||||
if (p.priority !== prev.priority && p.priority) setPriority(p.priority);
|
||||
if (p.due_date !== prev.due_date && p.due_date) setDue(p.due_date);
|
||||
if (p.estimate_min !== prev.estimate_min && p.estimate_min) setEstimate(p.estimate_min);
|
||||
if (p.repeat !== prev.repeat && p.repeat) setRepeat(p.repeat);
|
||||
if (p.project_id !== prev.project_id && p.project_id) setProjectId(p.project_id);
|
||||
if (p.context !== prev.context) setContext(p.context ?? null);
|
||||
const mirrored: Partial<TaskDraft> = {};
|
||||
if (p.priority !== prev.priority && p.priority) mirrored.priority = p.priority;
|
||||
if (p.due_date !== prev.due_date && p.due_date) mirrored.due_date = p.due_date;
|
||||
if (p.estimate_min !== prev.estimate_min && p.estimate_min) mirrored.estimate_min = p.estimate_min;
|
||||
if (p.repeat !== prev.repeat && p.repeat) mirrored.repeat = p.repeat;
|
||||
if (p.project_id !== prev.project_id && p.project_id) mirrored.project_id = p.project_id;
|
||||
if (p.context !== prev.context) mirrored.context = p.context ?? null;
|
||||
prevParsed.current = p;
|
||||
setDraft((d) => ({ ...d, ...patch, ...mirrored }));
|
||||
};
|
||||
|
||||
const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
|
||||
const tokensUsed = raw.trim() !== cleanTitle;
|
||||
// The field keeps the tokens visible; only the saved task loses them.
|
||||
const cleanTitle = parseQuickAdd(draft.title, projects).title.trim() || draft.title.trim();
|
||||
const tokensUsed = draft.title.trim() !== cleanTitle;
|
||||
const ready = cleanTitle.length > 0;
|
||||
|
||||
const save = () => {
|
||||
if (!ready) return;
|
||||
createTask({
|
||||
...draft,
|
||||
title: cleanTitle,
|
||||
notes,
|
||||
status: box,
|
||||
// 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,
|
||||
repeat,
|
||||
project_id: projectId,
|
||||
context,
|
||||
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
|
||||
priority: draft.priority ?? 3,
|
||||
waiting_for: draft.status === "waiting" ? draft.waiting_for?.trim() || null : null,
|
||||
});
|
||||
haptic.success();
|
||||
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 }}
|
||||
@@ -219,44 +70,24 @@ export default function AddTask() {
|
||||
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="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>
|
||||
)}
|
||||
|
||||
<AttrBar attrs={attrs} />
|
||||
<TaskForm
|
||||
value={draft}
|
||||
onChange={change}
|
||||
autoFocus
|
||||
onSubmitTitle={save}
|
||||
hint={
|
||||
tokensUsed
|
||||
? `Saving as “${cleanTitle}” — tokens moved into the chips below.`
|
||||
: "Type p1 · tmr · @home · ~30m · #project to fill these in as you go."
|
||||
}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.85 }]}
|
||||
onPress={save}
|
||||
disabled={!ready}
|
||||
>
|
||||
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>
|
||||
Add to {BOX_LABEL[box]}
|
||||
</Text>
|
||||
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>Add to {BOX_LABEL[draft.status]}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -264,50 +95,6 @@ export default function AddTask() {
|
||||
|
||||
const s = StyleSheet.create({
|
||||
content: { padding: space.lg, paddingBottom: space.xxl },
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import DateField from "../../components/DateField";
|
||||
import EstimateChips from "../../components/EstimateChips";
|
||||
import PriorityChips from "../../components/PriorityChips";
|
||||
import { Button, Chips, Empty, Field } from "../../components/ui";
|
||||
import { ScrollView, StyleSheet, Text } from "react-native";
|
||||
import TaskForm, { CAPTURE_BOXES, type TaskDraft } from "../../components/TaskForm";
|
||||
import { Button, Empty } from "../../components/ui";
|
||||
import { haptic } from "../../lib/haptics";
|
||||
import { REPEAT_LABEL, REPEATS } from "../../lib/repeat";
|
||||
import { activeProjects, knownContexts, trashTask, updateTask } from "../../lib/store";
|
||||
import { C, radius, space, type } from "../../lib/theme";
|
||||
import { trashTask, updateTask } from "../../lib/store";
|
||||
import { C, space, type } from "../../lib/theme";
|
||||
import { useStore } from "../../lib/useStore";
|
||||
import type { Repeat, Task, TaskStatus } from "../../lib/types";
|
||||
import type { Task, TaskStatus } from "../../lib/types";
|
||||
|
||||
const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"];
|
||||
// Clarify can also finish a task outright; capture never needs to.
|
||||
const BOXES: TaskStatus[] = [...CAPTURE_BOXES, "done"];
|
||||
|
||||
const toDraft = (t: Task): TaskDraft => ({
|
||||
title: t.title,
|
||||
notes: t.notes,
|
||||
status: t.status,
|
||||
project_id: t.project_id,
|
||||
context: t.context,
|
||||
waiting_for: t.waiting_for,
|
||||
due_date: t.due_date,
|
||||
defer_date: t.defer_date,
|
||||
estimate_min: t.estimate_min,
|
||||
repeat: t.repeat,
|
||||
priority: t.priority,
|
||||
});
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
@@ -20,44 +32,26 @@ export default function TaskDetail() {
|
||||
const store = useStore();
|
||||
const original = store.tasks.find((t) => t.id === id);
|
||||
// Draft is local to the screen; nothing is written until Save.
|
||||
const [draft, setDraft] = useState<Task | null>(original ?? null);
|
||||
const [draft, setDraft] = useState<TaskDraft | null>(original ? toDraft(original) : null);
|
||||
|
||||
if (!draft) return <Empty>Task not found.</Empty>;
|
||||
|
||||
const set = (patch: Partial<Task>) => setDraft({ ...draft, ...patch });
|
||||
if (!original || !draft) return <Empty>Task not found.</Empty>;
|
||||
|
||||
const save = () => {
|
||||
updateTask(draft.id, {
|
||||
title: draft.title,
|
||||
notes: draft.notes,
|
||||
status: draft.status,
|
||||
project_id: draft.project_id,
|
||||
context: draft.context,
|
||||
waiting_for: draft.waiting_for,
|
||||
due_date: draft.due_date,
|
||||
defer_date: draft.defer_date,
|
||||
priority: draft.priority,
|
||||
estimate_min: draft.estimate_min,
|
||||
repeat: draft.repeat,
|
||||
});
|
||||
updateTask(original.id, { ...draft, priority: draft.priority ?? 3 });
|
||||
haptic.success();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
|
||||
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);
|
||||
|
||||
if (draft.status === "trashed") {
|
||||
if (original.status === "trashed") {
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
|
||||
<Text style={s.trashedTitle}>{draft.title}</Text>
|
||||
<Text style={s.trashedTitle}>{original.title}</Text>
|
||||
<Text style={s.trashedMeta}>In the trash.</Text>
|
||||
<Button
|
||||
title="Restore to Next"
|
||||
onPress={() => {
|
||||
haptic.success();
|
||||
updateTask(draft.id, { status: "next" });
|
||||
updateTask(original.id, { status: "next" });
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
@@ -72,80 +66,19 @@ export default function TaskDetail() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
|
||||
<PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} />
|
||||
<EstimateChips value={draft.estimate_min} onChange={(estimate_min) => set({ estimate_min })} />
|
||||
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
|
||||
<Chips
|
||||
label="Project"
|
||||
options={projectOptions}
|
||||
labels={projectLabels}
|
||||
value={draft.project_id ?? "none"}
|
||||
onChange={(v) => set({ project_id: v === "none" ? null : v })}
|
||||
<TaskForm
|
||||
value={draft}
|
||||
onChange={(patch) => setDraft({ ...draft, ...patch })}
|
||||
boxes={BOXES}
|
||||
showDefer
|
||||
onSubmitTitle={save}
|
||||
/>
|
||||
<Field
|
||||
label="Context"
|
||||
value={draft.context ?? ""}
|
||||
onChangeText={(v) => set({ context: v || null })}
|
||||
placeholder="@home, @computer, @errands…"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{contexts.length > 0 && (
|
||||
<View style={s.contextRow}>
|
||||
{contexts.map((c) => {
|
||||
const active = draft.context === c;
|
||||
return (
|
||||
<Pressable
|
||||
key={c}
|
||||
style={({ pressed }) => [
|
||||
s.contextChip,
|
||||
active && s.contextChipActive,
|
||||
pressed && !active && { backgroundColor: C.surface2 },
|
||||
]}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
set({ context: active ? null : c });
|
||||
}}
|
||||
>
|
||||
<Text style={[s.contextText, active && s.contextTextActive]}>{c}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
{draft.status === "waiting" && (
|
||||
<Field
|
||||
label="Waiting for"
|
||||
value={draft.waiting_for ?? ""}
|
||||
onChangeText={(v) => set({ waiting_for: v || null })}
|
||||
placeholder="Who or what?"
|
||||
/>
|
||||
)}
|
||||
<DateField label="Due date" value={draft.due_date} onChange={(v) => set({ due_date: v })} clearable />
|
||||
<Chips
|
||||
label="Repeat"
|
||||
options={["none", ...REPEATS]}
|
||||
labels={{ none: "None", ...REPEAT_LABEL }}
|
||||
value={draft.repeat ?? "none"}
|
||||
onChange={(v) => set({ repeat: v === "none" ? null : (v as Repeat) })}
|
||||
/>
|
||||
<DateField label="Defer until" value={draft.defer_date} onChange={(v) => set({ defer_date: v })} clearable />
|
||||
{!!draft.defer_date && (
|
||||
<Text style={s.deferHint}>Hidden from lists until then — it resurfaces on the day.</Text>
|
||||
)}
|
||||
<Field
|
||||
label="Notes"
|
||||
value={draft.notes}
|
||||
onChangeText={(v) => set({ notes: v })}
|
||||
multiline
|
||||
style={{ minHeight: 96, textAlignVertical: "top" }}
|
||||
/>
|
||||
<Button title="Save" onPress={save} />
|
||||
<Button title="Save" onPress={save} style={s.save} />
|
||||
<Button
|
||||
title="Delete"
|
||||
danger
|
||||
onPress={() => {
|
||||
trashTask(draft.id);
|
||||
trashTask(original.id);
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
@@ -155,25 +88,7 @@ export default function TaskDetail() {
|
||||
|
||||
const s = StyleSheet.create({
|
||||
content: { padding: space.lg, paddingBottom: space.xxl },
|
||||
save: { marginTop: space.xl },
|
||||
trashedTitle: { ...type.title, fontSize: 22, color: C.text, marginBottom: space.xs },
|
||||
trashedMeta: { ...type.body, color: C.muted, marginBottom: space.xl },
|
||||
contextRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: space.sm,
|
||||
marginTop: -space.sm,
|
||||
marginBottom: space.lg,
|
||||
},
|
||||
contextChip: {
|
||||
paddingVertical: 7,
|
||||
paddingHorizontal: space.md + 1,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: C.border,
|
||||
},
|
||||
contextChipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||
contextText: { ...type.meta, color: C.textSecondary },
|
||||
contextTextActive: { color: C.accent, fontWeight: "600" },
|
||||
deferHint: { ...type.meta, color: C.muted, marginTop: -space.sm, marginBottom: space.md },
|
||||
});
|
||||
|
||||
287
app/src/components/TaskForm.tsx
Normal file
287
app/src/components/TaskForm.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
// The task editor, shared by capture and clarify.
|
||||
//
|
||||
// Both screens are the same object seen at two moments, so they get the same
|
||||
// shape: a title/notes card, then one wrapping row of attribute chips. Keeping
|
||||
// it in one file means a change to how "When" or "Repeat" is picked lands in
|
||||
// both places at once, and the two screens can never drift into looking like
|
||||
// different apps.
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import AttrBar, { type Attr } from "./AttrBar";
|
||||
import DateField from "./DateField";
|
||||
import EstimateChips from "./EstimateChips";
|
||||
import PriorityChips from "./PriorityChips";
|
||||
import { Chips, Separator } from "./ui";
|
||||
import { friendlyDate } from "../lib/dates";
|
||||
import { haptic } from "../lib/haptics";
|
||||
import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
|
||||
import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
|
||||
import { activeProjects, knownContexts } from "../lib/store";
|
||||
import { C, radius, space, type } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
import type { Task, TaskStatus } from "../lib/types";
|
||||
|
||||
/** Everything a task screen edits. Priority is nullable while undecided. */
|
||||
export type TaskDraft = Pick<
|
||||
Task,
|
||||
"title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat"
|
||||
> & { priority: Task["priority"] | null };
|
||||
|
||||
export const BOX_LABEL: Record<string, string> = {
|
||||
next: "Next up",
|
||||
waiting: "Waiting",
|
||||
someday: "Someday",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
/** The boxes a task can be filed into by hand. */
|
||||
export const CAPTURE_BOXES: TaskStatus[] = ["next", "waiting", "someday"];
|
||||
|
||||
export const emptyDraft = (): TaskDraft => ({
|
||||
title: "",
|
||||
notes: "",
|
||||
status: "next",
|
||||
project_id: null,
|
||||
context: null,
|
||||
waiting_for: null,
|
||||
due_date: null,
|
||||
defer_date: null,
|
||||
estimate_min: null,
|
||||
repeat: null,
|
||||
priority: null,
|
||||
});
|
||||
|
||||
export default function TaskForm({
|
||||
value: d,
|
||||
onChange,
|
||||
boxes = CAPTURE_BOXES,
|
||||
showDefer = false,
|
||||
autoFocus = false,
|
||||
onSubmitTitle,
|
||||
hint,
|
||||
}: {
|
||||
value: TaskDraft;
|
||||
onChange: (patch: Partial<TaskDraft>) => void;
|
||||
boxes?: TaskStatus[];
|
||||
/** Clarify offers a start date; capture keeps the bar short. */
|
||||
showDefer?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onSubmitTitle?: () => void;
|
||||
hint?: string;
|
||||
}) {
|
||||
const store = useStore();
|
||||
// Keep a project that has since been archived visible, or editing an old task
|
||||
// would silently drop it.
|
||||
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === d.project_id);
|
||||
const contexts = knownContexts(store);
|
||||
const projectName = projects.find((p) => p.id === d.project_id)?.name ?? null;
|
||||
|
||||
const attrs: Attr[] = [
|
||||
{
|
||||
key: "when",
|
||||
icon: "today",
|
||||
placeholder: "When",
|
||||
value: d.due_date ? friendlyDate(d.due_date) : null,
|
||||
panel: <DateField value={d.due_date} onChange={(due_date) => onChange({ due_date })} clearable />,
|
||||
onClear: () => onChange({ due_date: null }),
|
||||
},
|
||||
{
|
||||
key: "priority",
|
||||
icon: "flag",
|
||||
placeholder: "Priority",
|
||||
value: d.priority ? PRIORITY_LABEL[d.priority] : null,
|
||||
tone: d.priority ? PRIORITY_COLOR[d.priority] : undefined,
|
||||
panel: <PriorityChips value={d.priority} onChange={(priority) => onChange({ priority })} />,
|
||||
onClear: () => onChange({ priority: 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: d.status === "next" ? null : BOX_LABEL[d.status],
|
||||
panel: (
|
||||
<View>
|
||||
<Chips
|
||||
options={boxes}
|
||||
labels={BOX_LABEL}
|
||||
value={d.status}
|
||||
onChange={(status) => {
|
||||
haptic.select();
|
||||
onChange({ status });
|
||||
}}
|
||||
/>
|
||||
{d.status === "waiting" && (
|
||||
<TextInput
|
||||
style={s.panelInput}
|
||||
value={d.waiting_for ?? ""}
|
||||
onChangeText={(v) => onChange({ waiting_for: v || null })}
|
||||
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={d.project_id}
|
||||
onChange={(v) => {
|
||||
haptic.select();
|
||||
onChange({ project_id: v === d.project_id ? null : v });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onClear: () => onChange({ project_id: null }),
|
||||
},
|
||||
{
|
||||
key: "estimate",
|
||||
icon: "waiting",
|
||||
placeholder: "Estimate",
|
||||
value: formatEstimate(d.estimate_min),
|
||||
panel: <EstimateChips value={d.estimate_min} onChange={(estimate_min) => onChange({ estimate_min })} />,
|
||||
onClear: () => onChange({ estimate_min: null }),
|
||||
},
|
||||
{
|
||||
key: "repeat",
|
||||
icon: "repeat",
|
||||
placeholder: "Repeat",
|
||||
value: d.repeat ? REPEAT_LABEL[d.repeat] : null,
|
||||
panel: (
|
||||
<Chips
|
||||
options={REPEATS}
|
||||
labels={REPEAT_LABEL}
|
||||
value={d.repeat}
|
||||
onChange={(v) => {
|
||||
haptic.select();
|
||||
onChange({ repeat: v === d.repeat ? null : v });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onClear: () => onChange({ repeat: null }),
|
||||
},
|
||||
];
|
||||
|
||||
if (showDefer) {
|
||||
attrs.push({
|
||||
key: "defer",
|
||||
icon: "someday",
|
||||
placeholder: "Start",
|
||||
value: d.defer_date ? friendlyDate(d.defer_date) : null,
|
||||
panel: (
|
||||
<View>
|
||||
<DateField value={d.defer_date} onChange={(defer_date) => onChange({ defer_date })} clearable />
|
||||
<Text style={s.panelEmpty}>Hidden from lists until then — it resurfaces on the day.</Text>
|
||||
</View>
|
||||
),
|
||||
onClear: () => onChange({ defer_date: 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 || d.context) {
|
||||
const options = d.context && !contexts.includes(d.context) ? [...contexts, d.context] : contexts;
|
||||
attrs.push({
|
||||
key: "context",
|
||||
icon: "tag",
|
||||
placeholder: "Tag",
|
||||
value: d.context,
|
||||
panel: (
|
||||
<Chips
|
||||
options={options}
|
||||
value={d.context}
|
||||
onChange={(v) => {
|
||||
haptic.select();
|
||||
onChange({ context: v === d.context ? null : v });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onClear: () => onChange({ context: null }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={s.card}>
|
||||
<TextInput
|
||||
style={s.titleInput}
|
||||
value={d.title}
|
||||
onChangeText={(title) => onChange({ title })}
|
||||
placeholder="New to-do"
|
||||
placeholderTextColor={C.muted}
|
||||
autoFocus={autoFocus}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={onSubmitTitle}
|
||||
/>
|
||||
<Separator />
|
||||
<TextInput
|
||||
style={s.notesInput}
|
||||
value={d.notes}
|
||||
onChangeText={(notes) => onChange({ notes })}
|
||||
placeholder="Notes"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!!hint && <Text style={s.hint}>{hint}</Text>}
|
||||
|
||||
<AttrBar attrs={attrs} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
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,
|
||||
paddingVertical: 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 },
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type ViewStyle } from "react-native";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { C, radius, shadow, space, type } from "../lib/theme";
|
||||
|
||||
/**
|
||||
@@ -142,17 +142,19 @@ export function Button({
|
||||
onPress,
|
||||
danger,
|
||||
secondary,
|
||||
style,
|
||||
}: {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
danger?: boolean;
|
||||
secondary?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
|
||||
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [u.button, variant, pressed && { opacity: 0.75 }]}
|
||||
style={({ pressed }) => [u.button, variant, style, pressed && { opacity: 0.75 }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[u.buttonText, textVariant]}>{title}</Text>
|
||||
|
||||
Reference in New Issue
Block a user