refactor(task): one TaskForm behind both capture and clarify

This commit is contained in:
2026-09-01 22:55:48 +10:00
parent e299ab69bd
commit d951971d59
4 changed files with 365 additions and 374 deletions

View File

@@ -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,

View File

@@ -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 },
}); });

View 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 },
});

View File

@@ -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>