refactor(task): one TaskForm behind both capture and clarify
This commit is contained in:
@@ -1,28 +1,12 @@
|
|||||||
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, TextInput, View } from "react-native";
|
import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
|
||||||
import AttrBar, { type Attr } from "../components/AttrBar";
|
import TaskForm, { BOX_LABEL, emptyDraft, type TaskDraft } from "../components/TaskForm";
|
||||||
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 { 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 { activeProjects, createTask } from "../lib/store";
|
||||||
import { activeProjects, createTask, knownContexts } from "../lib/store";
|
import { C, radius, space } 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 } 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.
|
* Capture screen.
|
||||||
@@ -35,183 +19,50 @@ const BOXES = Object.keys(BOX_LABEL) as Box[];
|
|||||||
export default function AddTask() {
|
export default function AddTask() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const [raw, setRaw] = useState("");
|
const [draft, setDraft] = useState<TaskDraft>(emptyDraft);
|
||||||
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 projects = activeProjects(store).filter((p) => p.status === "active");
|
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")
|
// 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
|
// line but every inference stays visible and correctable. A token 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 change = (patch: Partial<TaskDraft>) => {
|
||||||
setRaw(text);
|
if (patch.title === undefined) return setDraft((d) => ({ ...d, ...patch }));
|
||||||
const p = parseQuickAdd(text, projects);
|
|
||||||
|
const p = parseQuickAdd(patch.title, projects);
|
||||||
const prev = prevParsed.current;
|
const prev = prevParsed.current;
|
||||||
if (p.priority !== prev.priority && p.priority) setPriority(p.priority);
|
const mirrored: Partial<TaskDraft> = {};
|
||||||
if (p.due_date !== prev.due_date && p.due_date) setDue(p.due_date);
|
if (p.priority !== prev.priority && p.priority) mirrored.priority = p.priority;
|
||||||
if (p.estimate_min !== prev.estimate_min && p.estimate_min) setEstimate(p.estimate_min);
|
if (p.due_date !== prev.due_date && p.due_date) mirrored.due_date = p.due_date;
|
||||||
if (p.repeat !== prev.repeat && p.repeat) setRepeat(p.repeat);
|
if (p.estimate_min !== prev.estimate_min && p.estimate_min) mirrored.estimate_min = p.estimate_min;
|
||||||
if (p.project_id !== prev.project_id && p.project_id) setProjectId(p.project_id);
|
if (p.repeat !== prev.repeat && p.repeat) mirrored.repeat = p.repeat;
|
||||||
if (p.context !== prev.context) setContext(p.context ?? null);
|
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;
|
prevParsed.current = p;
|
||||||
|
setDraft((d) => ({ ...d, ...patch, ...mirrored }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
|
// The field keeps the tokens visible; only the saved task loses them.
|
||||||
const tokensUsed = raw.trim() !== cleanTitle;
|
const cleanTitle = parseQuickAdd(draft.title, projects).title.trim() || draft.title.trim();
|
||||||
|
const tokensUsed = draft.title.trim() !== cleanTitle;
|
||||||
const ready = cleanTitle.length > 0;
|
const ready = cleanTitle.length > 0;
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
createTask({
|
createTask({
|
||||||
|
...draft,
|
||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
notes,
|
|
||||||
status: box,
|
|
||||||
// Unset priority means "ordinary", not "urgent" — pick the middle rung
|
// Unset priority means "ordinary", not "urgent" — pick the middle rung
|
||||||
// rather than blocking capture on a decision that can wait for review.
|
// rather than blocking capture on a decision that can wait for review.
|
||||||
priority: priority ?? 3,
|
priority: draft.priority ?? 3,
|
||||||
estimate_min: estimate,
|
waiting_for: draft.status === "waiting" ? draft.waiting_for?.trim() || null : null,
|
||||||
due_date: due,
|
|
||||||
repeat,
|
|
||||||
project_id: projectId,
|
|
||||||
context,
|
|
||||||
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
|
|
||||||
});
|
});
|
||||||
haptic.success();
|
haptic.success();
|
||||||
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 }}
|
||||||
@@ -219,44 +70,24 @@ export default function AddTask() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={s.card}>
|
<TaskForm
|
||||||
<TextInput
|
value={draft}
|
||||||
style={s.titleInput}
|
onChange={change}
|
||||||
value={raw}
|
autoFocus
|
||||||
onChangeText={onTitleChange}
|
onSubmitTitle={save}
|
||||||
placeholder="New to-do"
|
hint={
|
||||||
placeholderTextColor={C.muted}
|
tokensUsed
|
||||||
autoFocus
|
? `Saving as “${cleanTitle}” — tokens moved into the chips below.`
|
||||||
returnKeyType="done"
|
: "Type p1 · tmr · @home · ~30m · #project to fill these in as you go."
|
||||||
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} />
|
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.85 }]}
|
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]}>Add to {BOX_LABEL[draft.status]}</Text>
|
||||||
Add to {BOX_LABEL[box]}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@@ -264,50 +95,6 @@ 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 },
|
||||||
|
|
||||||
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: {
|
save: {
|
||||||
backgroundColor: C.accent,
|
backgroundColor: C.accent,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { ScrollView, StyleSheet, Text } from "react-native";
|
||||||
import DateField from "../../components/DateField";
|
import TaskForm, { CAPTURE_BOXES, type TaskDraft } from "../../components/TaskForm";
|
||||||
import EstimateChips from "../../components/EstimateChips";
|
import { Button, Empty } from "../../components/ui";
|
||||||
import PriorityChips from "../../components/PriorityChips";
|
|
||||||
import { Button, Chips, Empty, Field } from "../../components/ui";
|
|
||||||
import { haptic } from "../../lib/haptics";
|
import { haptic } from "../../lib/haptics";
|
||||||
import { REPEAT_LABEL, REPEATS } from "../../lib/repeat";
|
import { trashTask, updateTask } from "../../lib/store";
|
||||||
import { activeProjects, knownContexts, trashTask, updateTask } from "../../lib/store";
|
import { C, 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 { 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() {
|
export default function TaskDetail() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
@@ -20,44 +32,26 @@ export default function TaskDetail() {
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const original = store.tasks.find((t) => t.id === id);
|
const original = store.tasks.find((t) => t.id === id);
|
||||||
// Draft is local to the screen; nothing is written until Save.
|
// 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>;
|
if (!original || !draft) return <Empty>Task not found.</Empty>;
|
||||||
|
|
||||||
const set = (patch: Partial<Task>) => setDraft({ ...draft, ...patch });
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
updateTask(draft.id, {
|
updateTask(original.id, { ...draft, priority: draft.priority ?? 3 });
|
||||||
title: draft.title,
|
haptic.success();
|
||||||
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,
|
|
||||||
});
|
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
|
if (original.status === "trashed") {
|
||||||
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") {
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
|
<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>
|
<Text style={s.trashedMeta}>In the trash.</Text>
|
||||||
<Button
|
<Button
|
||||||
title="Restore to Next"
|
title="Restore to Next"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
haptic.success();
|
haptic.success();
|
||||||
updateTask(draft.id, { status: "next" });
|
updateTask(original.id, { status: "next" });
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -72,80 +66,19 @@ export default function TaskDetail() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
|
<TaskForm
|
||||||
<PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} />
|
value={draft}
|
||||||
<EstimateChips value={draft.estimate_min} onChange={(estimate_min) => set({ estimate_min })} />
|
onChange={(patch) => setDraft({ ...draft, ...patch })}
|
||||||
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
|
boxes={BOXES}
|
||||||
<Chips
|
showDefer
|
||||||
label="Project"
|
onSubmitTitle={save}
|
||||||
options={projectOptions}
|
|
||||||
labels={projectLabels}
|
|
||||||
value={draft.project_id ?? "none"}
|
|
||||||
onChange={(v) => set({ project_id: v === "none" ? null : v })}
|
|
||||||
/>
|
/>
|
||||||
<Field
|
<Button title="Save" onPress={save} style={s.save} />
|
||||||
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
|
<Button
|
||||||
title="Delete"
|
title="Delete"
|
||||||
danger
|
danger
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
trashTask(draft.id);
|
trashTask(original.id);
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -155,25 +88,7 @@ export default function TaskDetail() {
|
|||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
content: { padding: space.lg, paddingBottom: space.xxl },
|
content: { padding: space.lg, paddingBottom: space.xxl },
|
||||||
|
save: { marginTop: space.xl },
|
||||||
trashedTitle: { ...type.title, fontSize: 22, color: C.text, marginBottom: space.xs },
|
trashedTitle: { ...type.title, fontSize: 22, color: C.text, marginBottom: space.xs },
|
||||||
trashedMeta: { ...type.body, color: C.muted, marginBottom: space.xl },
|
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";
|
import { C, radius, shadow, space, type } from "../lib/theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,17 +142,19 @@ export function Button({
|
|||||||
onPress,
|
onPress,
|
||||||
danger,
|
danger,
|
||||||
secondary,
|
secondary,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
}) {
|
}) {
|
||||||
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
|
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
|
||||||
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
|
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed }) => [u.button, variant, pressed && { opacity: 0.75 }]}
|
style={({ pressed }) => [u.button, variant, style, pressed && { opacity: 0.75 }]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
<Text style={[u.buttonText, textVariant]}>{title}</Text>
|
<Text style={[u.buttonText, textVariant]}>{title}</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user