17 Commits
v1.14 ... v1.17

Author SHA1 Message Date
marcuspaico
eec9a431df chore: EAS build config (v1.17, signed with the EAS-managed keystore)
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
marcuspaico
af1bf5a514 feat(home): draggable top-3 with a visually distinct NOW slot; web drag via handles
Home preview reorders through the same reorderTask as the Next list, and row
0 gets the NOW treatment (tint, bigger title). On web the drag library's
drop handling never fires, so lists reorder through WebSortableList's
pointer-event grab handles instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
marcuspaico
125fb7df35 feat(task): undo toast for completing — mis-taps are one tap to take back
completeTask returns an undo closure that restores status + sort slot and
trashes the occurrence a repeat spawned. One global snackbar (5s,
replace-on-new) mounted in the root layout; every checkbox and Clarify's
save-as-done go through completeWithUndo. bun tests for the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
dc49854072 feat(web): installable PWA shell so the web build works as an iPhone home-screen app
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
There is no iOS binary — CI only produces a signed APK — so Safari's Add to
Home Screen is the only way onto an iPhone today. Without a manifest and the
apple-* meta tags that lands a bookmark that opens inside browser chrome, so
add both, plus a viewport that covers the notch and stops focus-zoom.
2026-09-01 23:36:55 +10:00
c990ce243b refactor(projects): reuse cardEdges for the project list
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
2026-09-01 23:24:32 +10:00
8019e7c4f0 refactor(ui): one cardEdges helper for the three list card stacks 2026-09-01 22:57:13 +10:00
d951971d59 refactor(task): one TaskForm behind both capture and clarify 2026-09-01 22:55:48 +10:00
e299ab69bd feat(add): collapse the capture form into a title card plus an attribute bar 2026-09-01 22:53:17 +10:00
5638aa9030 refactor(ui): one chip primitive for all four pickers; DateField reuses lib/dates 2026-09-01 22:48:35 +10:00
5f610e506b feat(icons): add flag, repeat, tag and close glyphs 2026-09-01 22:46:57 +10:00
9aba89a2a9 fix(ui): drop unsupported letterSpacing from headerTitleStyle 2026-09-01 22:44:33 +10:00
Claude
1398a5d54b UI: align home-screen widget and TaskRow with the palette tokens
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:39:46 +10:00
Claude
31934c7c27 UI: restyle weekly review, project detail and settings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:39:12 +10:00
Claude
b183c2849b UI: restyle task detail onto the new tokens
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:37:59 +10:00
Claude
dff9142e2e UI: restyle Today, Add and Projects onto the new tokens
Also fixes Field silently dropping its base input style when a caller
passed their own (the multiline Description box was unstyled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:37:26 +10:00
Claude
1af86cd27c UI: light Things-3 design system — tokens, task rows, chips, home
Replaces the dark navy palette with a light grouped-background system:
- theme.ts: full token set (space/radius/type/shadow) + light+dark palettes
- ui.tsx: Screen/Title/SectionHeader/Card/Separator/Pill/Empty primitives
- TaskRow: rounded-square checkbox carrying priority colour, inset separators
- TaskListScreen: rows grouped into a card, drag lift, refined deferred footer
- Icon.tsx: dependency-free geometric icon set (emoji don't take a tint)
- home: large title, Next-up card, "needs attention" rows, icon tab bar
- Priority/Estimate/Date chips unified on the accent-soft active state

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:36:26 +10:00
65efb632db Settings shows the installed build version (CI-stamped versionName) — confirms which release Obtainium delivered
All checks were successful
Build & Release APK / check (push) Successful in 16s
Build & Release APK / build (push) Successful in 2m16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:53:50 -07:00
36 changed files with 2251 additions and 685 deletions

View File

@@ -57,6 +57,12 @@
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "7a0325dd-ba60-415e-b367-e414890bacb3"
}
}
}
}
}

18
app/eas.json Normal file
View File

@@ -0,0 +1,18 @@
{
"cli": {
"version": ">= 20.0.0",
"appVersionSource": "local"
},
"build": {
"production": {
"android": {
"buildType": "apk"
},
"env": {
"VERSION_CODE": "17",
"VERSION_NAME": "1.17"
},
"node": "22.20.0"
}
}
}

13
app/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"expo": "~57.0.11",
"expo-application": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",
@@ -43,6 +44,9 @@
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"typescript": "~6.0.3"
},
"engines": {
"node": "22.x"
}
},
"node_modules/@adobe/css-tools": {
@@ -5838,6 +5842,15 @@
}
}
},
"node_modules/expo-application": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz",
"integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": {
"version": "57.0.9",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.9.tgz",

View File

@@ -7,6 +7,7 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"expo": "~57.0.11",
"expo-application": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",

BIN
app/public/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

16
app/public/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "GTD",
"short_name": "GTD",
"description": "Capture, clarify, do.",
"start_url": "/today",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#F7F7F9",
"theme_color": "#F7F7F9",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

49
app/src/app/+html.tsx Normal file
View File

@@ -0,0 +1,49 @@
// The HTML shell for the web build.
//
// The web export is the iPhone story — there is no iOS binary, so Safari's "Add
// to Home Screen" has to produce something that opens full-screen and looks
// installed rather than a bookmark with a browser chrome on top. That takes the
// manifest, the apple-* meta tags and a viewport that covers the notch; none of
// them are reachable from app.json, so the shell is written out by hand.
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
// Match the app background on both schemes so the status bar and the rubber-band
// overscroll never flash white against a dark list.
const background = `
:root { color-scheme: light dark; }
body { background-color: #F7F7F9; overscroll-behavior-y: none; }
@media (prefers-color-scheme: dark) { body { background-color: #0C0C0F; } }
`;
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/* viewport-fit=cover lets the layout run under the notch; disabling
user scaling stops iOS zooming in every time a title field focuses. */}
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="application-name" content="GTD" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="GTD" />
{/* "default" keeps the iOS status bar legible over a light header. */}
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F7F7F9" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0C0C0F" />
<ScrollViewStyleReset />
<style dangerouslySetInnerHTML={{ __html: background }} />
</head>
<body>{children}</body>
</html>
);
}

View File

@@ -2,10 +2,12 @@ import NetInfo from "@react-native-community/netinfo";
import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { AppState } from "react-native";
import { AppState, Pressable } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, subscribe, sync } from "../lib/store";
import { C } from "../lib/theme";
import Icon from "../components/Icon";
import { ToastHost } from "../components/Toast";
import { C, space } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() {
@@ -35,12 +37,16 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
<StatusBar style="dark" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text,
// No hairline under the bar: the header should melt into the grouped
// background so the in-content Title carries the hierarchy instead.
headerShadowVisible: false,
headerTitleStyle: { fontSize: 17, fontWeight: "600", color: C.text },
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: C.bg },
}}
>
@@ -49,8 +55,13 @@ export default function RootLayout() {
options={{
title: "GTD",
headerRight: () => (
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
⚙️
<Link href="/settings" asChild>
<Pressable
hitSlop={12}
style={({ pressed }) => [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
>
<Icon name="settings" size={22} color={C.textSecondary} />
</Pressable>
</Link>
),
}}
@@ -64,6 +75,7 @@ export default function RootLayout() {
<Stack.Screen name="review" options={{ title: "Weekly review" }} />
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
</Stack>
<ToastHost />
</GestureHandlerRootView>
);
}

View File

@@ -1,160 +1,108 @@
import { useRouter } from "expo-router";
import { useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
import DateField from "../components/DateField";
import EstimateChips from "../components/EstimateChips";
import PriorityChips from "../components/PriorityChips";
import { Chips, Field } from "../components/ui";
import TaskForm, { BOX_LABEL, emptyDraft, type TaskDraft } from "../components/TaskForm";
import { haptic } from "../lib/haptics";
import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
import { activeProjects, createTask } from "../lib/store";
import { C } from "../lib/theme";
import { C, radius, space } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { Repeat, Task, TaskStatus } from "../lib/types";
const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "next", label: "Next up" },
{ key: "waiting", label: "Waiting" },
{ key: "someday", label: "Someday" },
];
/**
* Capture screen.
*
* Deliberately one card and one row of chips. Everything optional lives behind
* a chip that shows its own value, so the common case — type a title, hit Add —
* is two interactions and never scrolls, while a fully specified task is still
* reachable without leaving the screen.
*/
export default function AddTask() {
const router = useRouter();
const store = useStore();
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("");
const [repeat, setRepeat] = useState<Repeat | null>(null);
const [box, setBox] = useState<TaskStatus>("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 projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
// The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
// and mirrors each one into the form controls below as you type, so capture
// is one line but every inference stays visible and correctable. A token
// only writes its field when it changes — chips tapped by hand stick.
// and mirrors each one into the chips 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;
// Every task must be born fully scoped: title + priority + due date.
const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim());
const ready = cleanTitle.length > 0 && priority !== null && dueValid;
// 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 || priority === null) return;
if (!ready) return;
createTask({
...draft,
title: cleanTitle,
notes,
status: box,
priority,
estimate_min: estimate,
due_date: due.trim(),
repeat,
project_id: projectId,
context,
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
// Unset priority means "ordinary", not "urgent" — pick the middle rung
// rather than blocking capture on a decision that can wait for review.
priority: draft.priority ?? 3,
waiting_for: draft.status === "waiting" ? draft.waiting_for?.trim() || null : null,
});
haptic.success();
router.back();
};
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field
label="Task"
value={raw}
onChangeText={onTitleChange}
placeholder="Pay rent tmr p1 @home ~30m"
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<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."
}
/>
{tokensUsed && (
<Text style={s.parsedHint}>
→ {cleanTitle || "…"}
{context ? ` · ${context}` : ""}
</Text>
)}
<PriorityChips value={priority} onChange={setPriority} />
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
<Chips
label="Repeat"
options={["none", ...REPEATS]}
labels={{ none: "None", ...REPEAT_LABEL }}
value={repeat ?? "none"}
onChange={(v) => {
haptic.select();
setRepeat(v === "none" ? null : (v as Repeat));
}}
/>
<EstimateChips value={estimate} onChange={setEstimate} />
<Chips
label="Box"
options={BOXES.map((b) => b.key)}
labels={Object.fromEntries(BOXES.map((b) => [b.key, b.label]))}
value={box}
onChange={(b) => {
haptic.select();
setBox(b);
}}
/>
{box === "waiting" && (
<Field label="Waiting for" value={waitingFor} onChangeText={setWaitingFor} placeholder="Who or what?" />
)}
<Chips
label="Project"
options={projectOptions}
labels={projectLabels}
value={projectId ?? "none"}
onChange={(v) => setProjectId(v === "none" ? null : v)}
/>
<Field
label="Description"
value={notes}
onChangeText={setNotes}
placeholder="Details, links, notes…"
multiline
style={{ minHeight: 64, textAlignVertical: "top" }}
/>
<Pressable style={[s.save, !ready && s.saveDisabled]} onPress={save} disabled={!ready}>
<Text style={[s.saveText, !ready && { color: C.muted }]}>
{ready ? "Add task" : "Needs a title, priority & due date"}
</Text>
<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[draft.status]}</Text>
</Pressable>
</ScrollView>
);
}
const s = StyleSheet.create({
parsedHint: { color: C.accent, fontSize: 13, marginTop: -8, marginBottom: 12 },
content: { padding: space.lg, paddingBottom: space.xxl },
save: {
backgroundColor: C.accent,
borderRadius: 10,
padding: 14,
borderRadius: radius.md,
paddingVertical: space.lg - 2,
alignItems: "center",
marginTop: 8,
marginBottom: 24,
marginTop: space.xl,
},
saveDisabled: { backgroundColor: C.surface },
saveText: { color: C.text, fontSize: 16, fontWeight: "600" },
saveDisabled: { backgroundColor: C.surface2 },
saveText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
saveTextDisabled: { color: C.muted },
});

View File

@@ -1,20 +1,47 @@
import { useFocusEffect, useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import TaskRow from "../components/TaskRow";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist";
import WebSortableList from "../components/WebSortableList";
import Icon, { type IconName } from "../components/Icon";
import TaskRow, { TaskSeparator } from "../components/TaskRow";
import { Card, cardEdges, Empty, Screen, SectionHeader, Title } from "../components/ui";
import { haptic } from "../lib/haptics";
import { daysSince, useLastReview } from "../lib/review";
import { todayTasks, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { reorderTask, todayTasks, visibleTasksByStatus } from "../lib/store";
import { C, radius, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
const MENU = [
{ icon: "📅", label: "Today", href: "/today" },
{ icon: "▶️", label: "Next", href: "/list/next" },
{ icon: "⏳", label: "Waiting", href: "/list/waiting" },
{ icon: "💭", label: "Someday", href: "/list/someday" },
{ icon: "📁", label: "Projects", href: "/projects" },
{ icon: "🔄", label: "Review", href: "/review" },
] as const;
const MENU: { icon: IconName; label: string; href: string }[] = [
{ icon: "today", label: "Today", href: "/today" },
{ icon: "next", label: "Next", href: "/list/next" },
{ icon: "waiting", label: "Waiting", href: "/list/waiting" },
{ icon: "someday", label: "Someday", href: "/list/someday" },
{ icon: "projects", label: "Projects", href: "/projects" },
{ icon: "review", label: "Review", href: "/review" },
];
const PREVIEW = 3;
/**
* One tappable line of status — overdue, review due, sync trouble. Rendered as
* a real row with a chevron rather than coloured micro-text, so it reads as
* something you can act on.
*/
function Alert({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
const body = (
<View style={s.alert}>
<View style={[s.dot, { backgroundColor: tone }]} />
<Text style={[s.alertText, { color: tone }]}>{text}</Text>
{!!onPress && <Icon name="chevron" size={16} color={tone} />}
</View>
);
if (!onPress) return body;
return (
<Pressable onPress={onPress} style={({ pressed }) => pressed && { opacity: 0.6 }}>
{body}
</Pressable>
);
}
export default function Home() {
const router = useRouter();
@@ -25,103 +52,176 @@ export default function Home() {
useFocusEffect(reloadReview);
const reviewDays = daysSince(lastReview);
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
const preview = next.slice(0, PREVIEW);
return (
<View style={s.screen}>
<View style={{ flex: 1 }}>
<Text style={s.sectionTitle}>Next up</Text>
{next.slice(0, 3).map((t) => (
<TaskRow key={t.id} task={t} />
))}
{next.length === 0 && <Text style={s.empty}>Nothing next. Tap + to add.</Text>}
{next.length > 3 && (
<Pressable onPress={() => router.push("/list/next")}>
<Text style={s.viewAll}>all next actions ({next.length}) →</Text>
<Screen>
<NestableScrollContainer contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<Title count={next.length}>Next up</Title>
{preview.length === 0 ? (
<Empty>Nothing queued. Add the next thing on your mind.</Empty>
) : Platform.OS === "web" ? (
<WebSortableList
data={preview}
onReorder={(id, to) => reorderTask(id, "next", to)}
separator={<TaskSeparator />}
renderItem={(t, i) => (
<View style={cardEdges(i, preview.length)}>
<TaskRow task={t} emphasized={i === 0} />
</View>
)}
/>
) : (
// Draggable so the "do this now" slot is a hand-picked choice, not
// just whatever sorted first. Preview indices are next[0..N], so a
// drop reorders the real Next list.
<NestableDraggableFlatList
data={preview}
keyExtractor={(t) => t.id}
activationDistance={12}
onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => {
if (from !== to) {
haptic.success();
reorderTask(data[to].id, "next", to);
}
}}
renderItem={({ item, drag, isActive, getIndex }) => {
const i = getIndex() ?? 0;
return (
<ScaleDecorator activeScale={1.02}>
<View style={[...cardEdges(i, preview.length), isActive && s.lifted]}>
<TaskRow task={item} emphasized={i === 0} onLongPress={drag} />
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
/>
)}
{next.length > PREVIEW && (
<Pressable
onPress={() => router.push("/list/next")}
style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
>
<Text style={s.viewAllText}>All {next.length} next actions</Text>
<Icon name="chevron" size={16} color={C.accent} />
</Pressable>
)}
{overdue.length > 0 && (
<Pressable onPress={() => router.push("/today")}>
<Text style={s.overdue}>{overdue.length} overdue →</Text>
</Pressable>
{(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
<>
<SectionHeader>Needs attention</SectionHeader>
<Card>
{overdue.length > 0 && (
<Alert
tone={C.danger}
text={`${overdue.length} overdue`}
onPress={() => router.push("/today")}
/>
)}
{reviewDue && (
<Alert
tone={C.warn}
text={
lastReview === null
? "No weekly review yet"
: `Weekly review is ${reviewDays} days overdue`
}
onPress={() => router.push("/review")}
/>
)}
{store.offline && (
<Alert
tone={C.textSecondary}
text={
store.pending > 0
? `Offline — ${store.pending} change${store.pending === 1 ? "" : "s"} queued`
: "Offline"
}
/>
)}
{!!store.syncError && <Alert tone={C.danger} text={store.syncError} />}
</Card>
</>
)}
{reviewDue && (
<Pressable onPress={() => router.push("/review")}>
<Text style={s.reviewNudge}>
{lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
</Text>
</Pressable>
)}
{store.offline && (
<Text style={s.offline}>
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""}
</Text>
)}
{store.syncError && <Text style={s.error}>{store.syncError}</Text>}
</View>
<View style={s.menu}>
</NestableScrollContainer>
<View style={s.tabBar}>
{MENU.map((m) => (
<Pressable key={m.label} style={s.menuItem} onPress={() => router.push(m.href)}>
<Text style={s.menuIcon}>{m.icon}</Text>
<Text style={s.menuLabel}>{m.label}</Text>
<Pressable
key={m.label}
style={({ pressed }) => [s.tab, pressed && { opacity: 0.5 }]}
onPress={() => {
haptic.tap();
router.push(m.href);
}}
>
<Icon name={m.icon} size={21} color={C.textSecondary} bg={C.surface} />
<Text style={s.tabLabel}>{m.label}</Text>
</Pressable>
))}
</View>
<Pressable
style={s.fab}
style={({ pressed }) => [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
onPress={() => {
haptic.tap();
router.push("/add");
}}
>
<Text style={s.fabText}>+</Text>
<Icon name="plus" size={26} color={C.onAccent} />
</Pressable>
</View>
</Screen>
);
}
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
sectionTitle: {
color: C.muted,
fontSize: 13,
textTransform: "uppercase",
letterSpacing: 1,
paddingHorizontal: 16,
paddingTop: 18,
paddingBottom: 6,
},
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
viewAll: { color: C.muted, fontSize: 14, padding: 16 },
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
reviewNudge: { color: C.warn, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
menu: {
content: { paddingHorizontal: space.lg, paddingBottom: 120 },
lifted: { borderRadius: radius.lg, ...shadow.fab },
viewAll: {
flexDirection: "row",
alignItems: "center",
gap: space.xs,
paddingVertical: space.md,
paddingHorizontal: space.xs,
},
viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
alert: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md + 1,
paddingHorizontal: space.lg,
},
dot: { width: 7, height: 7, borderRadius: 999 },
alertText: { ...type.body, flex: 1, fontWeight: "500" },
tabBar: {
flexDirection: "row",
backgroundColor: C.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: C.border,
paddingTop: 10,
paddingBottom: 18,
backgroundColor: C.bg,
paddingTop: space.md,
paddingBottom: space.xxl,
},
menuItem: { flex: 1, alignItems: "center", gap: 3 },
menuIcon: { fontSize: 20 },
menuLabel: { color: C.muted, fontSize: 11 },
tab: { flex: 1, alignItems: "center", gap: 5 },
tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
fab: {
position: "absolute",
right: 20,
bottom: 92,
width: 58,
height: 58,
borderRadius: 29,
right: space.xl,
bottom: 104,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: C.accent,
alignItems: "center",
justifyContent: "center",
elevation: 6,
shadowColor: "#000",
shadowOpacity: 0.35,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
...shadow.fab,
},
fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" },
});

View File

@@ -1,9 +1,12 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { Button, Chips } from "../../components/ui";
import { createTask, dropProject, updateProject, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
import Icon from "../../components/Icon";
import { Button, cardEdges, Chips, Empty, Separator } from "../../components/ui";
import { haptic } from "../../lib/haptics";
import { createTask, dropProject, updateProject } from "../../lib/store";
import { completeWithUndo } from "../../lib/undo";
import { C, radius, space, type } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
import type { ProjectStatus } from "../../lib/types";
@@ -16,7 +19,7 @@ export default function ProjectDetail() {
const [draft, setDraft] = useState("");
const project = store.projects.find((p) => p.id === id);
if (!project) return <Text style={{ color: C.muted, margin: 16 }}>Project not found.</Text>;
if (!project) return <Empty>Project not found.</Empty>;
const tasks = store.tasks
.filter((t) => t.project_id === id && t.status !== "done" && t.status !== "trashed")
@@ -26,13 +29,14 @@ export default function ProjectDetail() {
const title = draft.trim();
if (!title) return;
setDraft("");
haptic.success();
createTask({ title, status: "next", project_id: id });
};
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
<Stack.Screen options={{ title: project.name }} />
<View style={{ padding: 16, paddingBottom: 0 }}>
<View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}>
<Chips
label="Status"
options={STATUSES}
@@ -53,22 +57,35 @@ export default function ProjectDetail() {
<FlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<Pressable style={s.row} onPress={() => router.push(`/task/${item.id}`)}>
renderItem={({ item, index }) => (
<Pressable
style={({ pressed }) => [
s.row,
...cardEdges(index, tasks.length),
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/task/${item.id}`)}
>
<Pressable
style={s.checkbox}
hitSlop={10}
onPress={() => updateTask(item.id, { status: "done" })}
onPress={() => completeWithUndo(item)}
/>
<View style={{ flex: 1 }}>
<Text style={s.title}>{item.title}</Text>
<Text style={s.title} numberOfLines={2}>
{item.title}
</Text>
<Text style={s.meta}>{[item.status, item.context].filter(Boolean).join(" · ")}</Text>
</View>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
)}
ListEmptyComponent={<Text style={s.empty}>No open tasks in this project.</Text>}
ItemSeparatorComponent={() => <Separator inset={space.lg + 21 + space.md} />}
ListEmptyComponent={<Empty>No open tasks in this project.</Empty>}
contentContainerStyle={s.listContent}
showsVerticalScrollIndicator={false}
/>
<View style={{ padding: 16 }}>
<View style={{ paddingHorizontal: space.lg, paddingBottom: space.lg }}>
<Button
title="Drop project"
danger
@@ -84,27 +101,26 @@ export default function ProjectDetail() {
const s = StyleSheet.create({
capture: {
margin: 12,
marginBottom: 4,
padding: 14,
borderRadius: 12,
marginHorizontal: space.lg,
marginBottom: space.lg,
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
borderRadius: radius.md,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
listContent: { paddingHorizontal: space.lg, paddingBottom: space.lg },
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
},
checkbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: C.muted },
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
empty: { color: C.muted, textAlign: "center", marginTop: 48 },
checkbox: { width: 21, height: 21, borderRadius: 7, borderWidth: 1.5, borderColor: C.muted },
title: { ...type.row, color: C.text },
meta: { ...type.meta, color: C.muted, marginTop: 2 },
});

View File

@@ -1,8 +1,11 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import Icon from "../components/Icon";
import { cardEdges, Empty, Pill, Separator } from "../components/ui";
import { haptic } from "../lib/haptics";
import { activeProjects, createProject, openTaskCount } from "../lib/store";
import { C } from "../lib/theme";
import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
export default function Projects() {
@@ -15,6 +18,7 @@ export default function Projects() {
const name = draft.trim();
if (!name) return;
setDraft("");
haptic.success();
createProject({ name });
};
@@ -32,18 +36,34 @@ export default function Projects() {
<FlatList
data={projects}
keyExtractor={(p) => p.id}
renderItem={({ item }) => (
<Pressable style={s.row} onPress={() => router.push(`/project/${item.id}`)}>
<View style={{ flex: 1 }}>
<Text style={s.title}>{item.name}</Text>
<Text style={s.meta}>
{item.status} · {openTaskCount(store, item.id)} open
</Text>
</View>
<Text style={{ color: C.muted }}>›</Text>
</Pressable>
)}
ListEmptyComponent={<Text style={s.empty}>No projects yet.</Text>}
renderItem={({ item, index }) => {
const open = openTaskCount(store, item.id);
return (
<Pressable
style={({ pressed }) => [
s.row,
...cardEdges(index, projects.length),
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/project/${item.id}`)}
>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>
{item.name}
</Text>
<Text style={s.meta}>
{open === 0 ? "Nothing open" : `${open} open`}
</Text>
</View>
{item.status !== "active" && <Pill label={item.status} />}
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
);
}}
ItemSeparatorComponent={() => <Separator inset={space.lg} />}
ListEmptyComponent={<Empty>No projects yet. Name one above.</Empty>}
contentContainerStyle={s.content}
showsVerticalScrollIndicator={false}
/>
</View>
);
@@ -51,26 +71,27 @@ export default function Projects() {
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
content: { paddingHorizontal: space.lg, paddingBottom: 40 },
capture: {
margin: 12,
marginBottom: 4,
padding: 14,
borderRadius: 12,
marginHorizontal: space.lg,
marginTop: space.md,
marginBottom: space.lg,
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
borderRadius: radius.md,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
row: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
},
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
empty: { color: C.muted, textAlign: "center", marginTop: 48 },
title: { ...type.row, color: C.text },
meta: { ...type.meta, color: C.muted, marginTop: 2 },
});

View File

@@ -3,33 +3,41 @@
// done on the checkbox, so nothing needs a trip through the edit modal.
import { useRouter } from "expo-router";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Button } from "../components/ui";
import Icon from "../components/Icon";
import { Button, Card, Empty, SectionHeader } from "../components/ui";
import { friendlyDate } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { PRIORITY_COLOR } from "../lib/priority";
import { daysSince, markReviewed, useLastReview } from "../lib/review";
import { reviewBuckets, trashTask, updateTask } from "../lib/store";
import { C } from "../lib/theme";
import { completeWithUndo } from "../lib/undo";
import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { Task } from "../lib/types";
function ReviewRow({ task, showDefer }: { task: Task; showDefer?: boolean }) {
function ReviewRow({
task,
showDefer,
last,
}: {
task: Task;
showDefer?: boolean;
/** The card already draws the bottom edge — don't double it up. */
last?: boolean;
}) {
const router = useRouter();
const act = (fn: () => void) => () => {
haptic.select();
fn();
};
return (
<View style={s.row}>
<View style={[s.row, last && s.rowLast]}>
<Pressable
style={s.check}
hitSlop={8}
onPress={act(() => {
haptic.success();
updateTask(task.id, { status: "done" });
})}
onPress={() => completeWithUndo(task)}
>
<View style={[s.checkCircle, { borderColor: PRIORITY_COLOR[task.priority] }]} />
<View style={[s.checkbox, { borderColor: PRIORITY_COLOR[task.priority] }]} />
</Pressable>
<Pressable style={{ flex: 1 }} onPress={() => router.push(`/task/${task.id}`)}>
<Text style={s.title} numberOfLines={2}>
@@ -46,17 +54,29 @@ function ReviewRow({ task, showDefer }: { task: Task; showDefer?: boolean }) {
</Text>
</Pressable>
{task.status !== "next" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "next" }))}>
<Text style={s.actionText}>▶️</Text>
<Pressable
style={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => updateTask(task.id, { status: "next" }))}
>
<Icon name="next" size={14} color={C.accent} />
</Pressable>
)}
{task.status !== "someday" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "someday" }))}>
<Text style={s.actionText}>💭</Text>
<Pressable
style={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => updateTask(task.id, { status: "someday" }))}
>
<Icon name="someday" size={14} color={C.textSecondary} bg={C.surface} />
</Pressable>
)}
<Pressable style={s.action} hitSlop={6} onPress={act(() => trashTask(task.id))}>
<Text style={s.actionText}>🗑️</Text>
<Pressable
style={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => trashTask(task.id))}
>
<Text style={s.dropText}>Drop</Text>
</Pressable>
</View>
);
@@ -65,14 +85,16 @@ function ReviewRow({ task, showDefer }: { task: Task; showDefer?: boolean }) {
function Section({ title, hint, tasks, showDefer }: { title: string; hint: string; tasks: Task[]; showDefer?: boolean }) {
if (tasks.length === 0) return null;
return (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>
<View>
<SectionHeader>
{title} ({tasks.length})
</Text>
</SectionHeader>
<Text style={s.sectionHint}>{hint}</Text>
{tasks.map((t) => (
<ReviewRow key={t.id} task={t} showDefer={showDefer} />
))}
<Card>
{tasks.map((t, i) => (
<ReviewRow key={t.id} task={t} showDefer={showDefer} last={i === tasks.length - 1} />
))}
</Card>
</View>
);
}
@@ -93,7 +115,7 @@ export default function Review() {
!buckets.stuckProjects.length;
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
<Text style={s.lastReview}>
{last ? (days === 0 ? "Last review: today" : `Last review: ${days}d ago`) : "First review — welcome."}
</Text>
@@ -105,22 +127,32 @@ export default function Review() {
<Section title="Deferred" hint="Hidden from lists — just so you know they're coming." tasks={buckets.deferred} showDefer />
{buckets.stuckProjects.length > 0 && (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>Stuck projects ({buckets.stuckProjects.length})</Text>
<View>
<SectionHeader>Stuck projects ({buckets.stuckProjects.length})</SectionHeader>
<Text style={s.sectionHint}>Active, but no next action — decide the next step or park them.</Text>
{buckets.stuckProjects.map((p) => (
<Pressable key={p.id} style={s.row} onPress={() => router.push(`/project/${p.id}`)}>
<Text style={[s.title, { flex: 1 }]}>{p.name}</Text>
<Text style={s.meta}>open →</Text>
</Pressable>
))}
<Card>
{buckets.stuckProjects.map((p, i) => (
<Pressable
key={p.id}
style={({ pressed }) => [
s.row,
i === buckets.stuckProjects.length - 1 && s.rowLast,
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/project/${p.id}`)}
>
<Text style={[s.title, { flex: 1 }]}>{p.name}</Text>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
))}
</Card>
</View>
)}
{empty && <Text style={s.empty}>All clear. Nothing needs a decision.</Text>}
{empty && <Empty>All clear. Nothing needs a decision.</Empty>}
<Button
title="✓ Mark review complete"
title="Mark review complete"
onPress={async () => {
haptic.success();
await markReviewed();
@@ -142,30 +174,35 @@ export default function Review() {
}
const s = StyleSheet.create({
lastReview: { color: C.muted, fontSize: 13, marginBottom: 12 },
sectionTitle: {
color: C.text,
fontSize: 15,
fontWeight: "700",
paddingTop: 12,
},
sectionHint: { color: C.muted, fontSize: 13, marginBottom: 4 },
content: { padding: space.lg, paddingBottom: 48 },
lastReview: { ...type.meta, color: C.textSecondary, marginBottom: space.xs },
sectionHint: { ...type.meta, color: C.muted, paddingHorizontal: space.xs, marginBottom: space.sm },
row: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingVertical: 12,
gap: space.md,
paddingVertical: space.md,
paddingHorizontal: space.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
borderBottomColor: C.separator,
backgroundColor: C.surface,
},
rowLast: { borderBottomWidth: 0 },
check: { paddingRight: 2 },
checkCircle: { width: 20, height: 20, borderRadius: 10, borderWidth: 2 },
title: { color: C.text, fontSize: 15 },
meta: { color: C.muted, fontSize: 12, marginTop: 2 },
action: { paddingHorizontal: 4 },
actionText: { fontSize: 17 },
empty: { color: C.muted, textAlign: "center", marginVertical: 32, fontSize: 15 },
archiveRow: { flexDirection: "row", justifyContent: "center", gap: 8, marginTop: 4 },
archiveLink: { color: C.muted, fontSize: 14, padding: 6 },
checkbox: { width: 21, height: 21, borderRadius: 7, borderWidth: 1.5 },
title: { ...type.body, color: C.text },
meta: { ...type.meta, fontSize: 12, color: C.muted, marginTop: 2 },
action: {
minWidth: 30,
height: 28,
paddingHorizontal: space.sm,
borderRadius: radius.sm,
alignItems: "center",
justifyContent: "center",
},
actionPressed: { backgroundColor: C.surface2 },
dropText: { ...type.meta, color: C.danger, fontWeight: "500" },
archiveRow: { flexDirection: "row", justifyContent: "center", gap: space.sm, marginTop: space.xs },
archiveLink: { ...type.meta, color: C.textSecondary, padding: space.sm },
archiveDot: { color: C.muted, alignSelf: "center" },
});

View File

@@ -1,11 +1,12 @@
import * as Application from "expo-application";
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ScrollView, Text } from "react-native";
import { ScrollView, StyleSheet, Text } from "react-native";
import { Button, Field } from "../components/ui";
import { api } from "../lib/api";
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
import { sync } from "../lib/store";
import { C } from "../lib/theme";
import { C, space, type } from "../lib/theme";
export default function Settings() {
const router = useRouter();
@@ -32,7 +33,7 @@ export default function Settings() {
};
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
<Field label="Server" value={url} onChangeText={setUrl} autoCapitalize="none" autoCorrect={false} />
<Field
label="API token"
@@ -43,7 +44,15 @@ export default function Settings() {
secureTextEntry
/>
<Button title="Save & test" onPress={save} />
{status && <Text style={{ color: C.danger }}>{status}</Text>}
{status && <Text style={s.error}>{status}</Text>}
{/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */}
<Text style={s.version}>GTD v{Application.nativeApplicationVersion ?? "dev"}</Text>
</ScrollView>
);
}
const s = StyleSheet.create({
content: { padding: space.lg },
error: { ...type.meta, color: C.danger, marginTop: space.xs },
version: { ...type.meta, color: C.muted, textAlign: "center", marginTop: space.xxl },
});

View File

@@ -1,18 +1,31 @@
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, 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 } from "../../lib/theme";
import { trashTask, updateTask } from "../../lib/store";
import { completeWithUndo } from "../../lib/undo";
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 +33,31 @@ 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 <Text style={{ color: C.muted, margin: 16 }}>Task not found.</Text>;
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,
});
const { status, ...rest } = draft;
const completing = status === "done" && original.status !== "done";
// Completing from Clarify gets the same Undo as a checkbox; the toast
// lives in the root layout so it outlives this modal.
updateTask(original.id, { ...rest, ...(completing ? {} : { status }), priority: draft.priority ?? 3 });
if (completing) completeWithUndo(original, original.status);
else 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={{ padding: 16 }}>
<Text style={s.trashedTitle}>{draft.title}</Text>
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
<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();
}}
/>
@@ -66,74 +66,25 @@ export default function TaskDetail() {
}
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<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 })}
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<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) => (
<Pressable
key={c}
style={[s.contextChip, draft.context === c && s.contextChipActive]}
onPress={() => {
haptic.select();
set({ context: draft.context === c ? null : c });
}}
>
<Text style={[s.contextText, draft.context === c && { color: C.text }]}>{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: 80, 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();
}}
/>
@@ -142,18 +93,8 @@ export default function TaskDetail() {
}
const s = StyleSheet.create({
trashedTitle: { color: C.text, fontSize: 18, marginBottom: 6 },
trashedMeta: { color: C.muted, fontSize: 14, marginBottom: 18 },
contextRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: -6, marginBottom: 14 },
contextChip: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 16,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
contextChipActive: { backgroundColor: C.surface2, borderColor: C.accent },
contextText: { color: C.muted, fontSize: 13 },
deferHint: { color: C.muted, fontSize: 13, marginTop: -8, marginBottom: 12 },
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 },
});

View File

@@ -1,7 +1,8 @@
import { SectionList, StyleSheet, Text } from "react-native";
import TaskRow from "../components/TaskRow";
import { SectionList, StyleSheet, View } from "react-native";
import TaskRow, { TaskSeparator } from "../components/TaskRow";
import { cardEdges, Empty, SectionHeader } from "../components/ui";
import { todayTasks } from "../lib/store";
import { C } from "../lib/theme";
import { C, space } from "../lib/theme";
import { useStore } from "../lib/useStore";
export default function Today() {
@@ -9,26 +10,35 @@ export default function Today() {
const { overdue, today } = todayTasks(store);
const sections = [
...(overdue.length ? [{ title: "Overdue", data: overdue, color: C.danger }] : []),
{ title: "Today", data: today, color: C.text },
{ title: "Today", data: today, color: C.textSecondary },
];
const empty = overdue.length === 0 && today.length === 0;
return (
<SectionList
style={{ flex: 1, backgroundColor: C.bg }}
style={s.screen}
sections={sections}
keyExtractor={(t) => t.id}
renderItem={({ item }) => <TaskRow task={item} showStatus />}
renderSectionHeader={({ section }) => (
<Text style={[s.header, { color: section.color }]}>{section.title}</Text>
// Each section is its own rounded card, so the first and last rows in a
// section round their outer corners.
renderItem={({ item, index, section }) => (
<View style={cardEdges(index, section.data.length)}>
<TaskRow task={item} showStatus />
</View>
)}
ListEmptyComponent={<Text style={s.empty}>Nothing due. Clear runway.</Text>}
contentContainerStyle={{ paddingBottom: 32 }}
renderSectionHeader={({ section }) => (
<SectionHeader tone={section.color}>{section.title}</SectionHeader>
)}
ItemSeparatorComponent={TaskSeparator}
ListFooterComponent={empty ? <Empty>Nothing due. Clear runway.</Empty> : null}
contentContainerStyle={s.content}
stickySectionHeadersEnabled={false}
showsVerticalScrollIndicator={false}
/>
);
}
const s = StyleSheet.create({
header: { fontSize: 15, fontWeight: "700", paddingHorizontal: 16, paddingTop: 18, paddingBottom: 6 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
screen: { flex: 1, backgroundColor: C.bg },
content: { paddingHorizontal: space.lg, paddingBottom: 40 },
});

View File

@@ -0,0 +1,126 @@
// The attribute bar: one wrapping row of chips that doubles as the task's
// summary and as the entry point to every optional field.
//
// The old form stacked seven labelled control groups down the screen, so
// capturing "call Sam" meant scrolling past a calendar, a repeat picker and an
// estimate picker to reach the save button. Here each field collapses to a
// single chip: unset chips read as a quiet placeholder, set chips show their
// value in the accent. Exactly one panel is open at a time, so the screen
// never grows past roughly a phone height.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { C, radius, space, type } from "../lib/theme";
import Icon, { type IconName } from "./Icon";
export type Attr = {
key: string;
icon: IconName;
/** Chip label while the field is unset. */
placeholder: string;
/** Chip label once set — this is what makes the bar readable as a summary. */
value?: string | null;
/** Chip accent when set. Defaults to the app accent. */
tone?: string;
/** Revealed under the bar when the chip is tapped. */
panel: React.ReactNode;
/** Offered as "Clear" in the open panel's header. Omit for required fields. */
onClear?: () => void;
};
export default function AttrBar({ attrs }: { attrs: Attr[] }) {
const [open, setOpen] = useState<string | null>(null);
const active = attrs.find((a) => a.key === open) ?? null;
return (
<View>
<View style={s.row}>
{attrs.map((a) => {
const set = !!a.value;
const tone = a.tone ?? C.accent;
const isOpen = a.key === open;
return (
<Pressable
key={a.key}
onPress={() => {
haptic.tap();
setOpen(isOpen ? null : a.key);
}}
style={({ pressed }) => [
s.chip,
set && { borderColor: tone, backgroundColor: `${tone}14` },
isOpen && { borderColor: tone, backgroundColor: `${tone}22` },
pressed && { opacity: 0.7 },
]}
>
<Icon name={a.icon} size={15} color={set || isOpen ? tone : C.muted} bg={C.bg} />
<Text style={[s.chipText, (set || isOpen) && { color: tone, fontWeight: "600" }]}>
{a.value || a.placeholder}
</Text>
</Pressable>
);
})}
</View>
{!!active && (
<View style={s.panel}>
<View style={s.panelHead}>
<Text style={s.panelTitle}>{active.placeholder}</Text>
{!!active.onClear && !!active.value && (
<Pressable
onPress={() => {
haptic.select();
active.onClear?.();
}}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
>
<Text style={s.clear}>Clear</Text>
</Pressable>
)}
<Pressable onPress={() => setOpen(null)} hitSlop={10} style={({ pressed }) => pressed && { opacity: 0.6 }}>
<Icon name="close" size={17} color={C.muted} />
</Pressable>
</View>
{active.panel}
</View>
)}
</View>
);
}
const s = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: space.sm - 1,
paddingLeft: space.md - 2,
paddingRight: space.lg - 2,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
backgroundColor: C.surface,
},
chipText: { ...type.chip, color: C.textSecondary },
panel: {
marginTop: space.md,
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
paddingHorizontal: space.lg,
paddingBottom: space.md,
},
panelHead: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingTop: space.md,
paddingBottom: space.xs,
},
panelTitle: { ...type.section, color: C.textSecondary, flex: 1 },
clear: { ...type.meta, color: C.accent, fontWeight: "600" },
});

View File

@@ -3,7 +3,9 @@
import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { C } from "../lib/theme";
import { friendlyDate, fromIso, toIso as iso, todayIso } from "../lib/dates";
import { C, radius, space, type } from "../lib/theme";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
const DAY_MS = 86_400_000;
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
@@ -12,31 +14,11 @@ const MONTHS = [
"July", "August", "September", "October", "November", "December",
];
function iso(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function fromIso(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
function pretty(s: string): string {
const d = fromIso(s);
const today = new Date();
const diff = Math.round((d.getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / DAY_MS);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
const wd = d.toLocaleDateString("en-AU", { weekday: "short" });
return `${wd} ${d.getDate()} ${MONTHS[d.getMonth()].slice(0, 3)}${d.getFullYear() !== today.getFullYear() ? ` ${d.getFullYear()}` : ""}`;
}
function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) => void }) {
const initial = value ? fromIso(value) : new Date();
const [year, setYear] = useState(initial.getFullYear());
const [month, setMonth] = useState(initial.getMonth());
const todayIso = iso(new Date());
const today = todayIso();
const weeks = useMemo(() => {
const first = new Date(year, month, 1);
@@ -84,7 +66,7 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
const dIso = iso(d);
const inMonth = d.getMonth() === month;
const selected = dIso === value;
const isToday = dIso === todayIso;
const isToday = dIso === today;
return (
<Pressable
key={dIso}
@@ -97,8 +79,8 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
<Text
style={[
s.dayText,
!inMonth && { color: C.border },
selected && { color: "#FFFFFF", fontWeight: "700" },
!inMonth && { color: C.muted, opacity: 0.45 },
selected && { color: C.onAccent, fontWeight: "700" },
!selected && isToday && { color: C.accent },
]}
>
@@ -119,7 +101,7 @@ export default function DateField({
onChange,
clearable,
}: {
label: string;
label?: string;
value: string | null;
onChange: (v: string | null) => void;
clearable?: boolean;
@@ -133,45 +115,41 @@ export default function DateField({
];
return (
<View style={{ marginBottom: 14 }}>
<View style={s.labelRow}>
<Text style={s.label}>{label}</Text>
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
</View>
<View style={s.rowWrap}>
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && (
<View style={s.labelRow}>
<SectionHeader>{label}</SectionHeader>
{value != null && <Text style={s.chosen}>{friendlyDate(value)}</Text>}
</View>
)}
<ChipRow>
{quick.map((q) => (
<Pressable
<OptionChip
key={q.label}
style={[s.chip, value === q.v && s.chipActive]}
label={q.label}
active={value === q.v}
onPress={() => {
haptic.select();
onChange(value === q.v && clearable ? null : q.v);
}}
>
<Text style={[s.chipText, value === q.v && { color: C.text }]}>{q.label}</Text>
</Pressable>
/>
))}
<Pressable
style={[s.chip, open && s.chipActive]}
<OptionChip
label={value && !quick.some((q) => q.v === value) ? friendlyDate(value) : "Pick a date…"}
active={open}
onPress={() => {
haptic.select();
setOpen(!open);
}}
>
<Text style={[s.chipText, open && { color: C.text }]}>📆 Pick…</Text>
</Pressable>
{clearable && value != null && (
<Pressable
style={s.chip}
onPress={() => {
haptic.tap();
onChange(null);
}}
>
<Text style={s.chipText}>Clear</Text>
</Pressable>
/>
{/* Standalone only — inside an AttrBar panel the header owns "Clear". */}
{clearable && !!label && value != null && (
<OptionChip label="Clear" onPress={() => {
haptic.tap();
onChange(null);
}} />
)}
</View>
</ChipRow>
{open && (
<Calendar
value={value}
@@ -186,34 +164,22 @@ export default function DateField({
}
const s = StyleSheet.create({
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 },
label: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 },
chosen: { color: C.accent, fontSize: 14, fontWeight: "600" },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
calendar: {
marginTop: 10,
marginTop: space.md,
backgroundColor: C.surface,
borderRadius: 14,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: C.border,
padding: 10,
padding: space.md,
},
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 6 },
calTitle: { color: C.text, fontSize: 15, fontWeight: "600" },
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: space.sm },
calTitle: { color: C.text, fontSize: 16, fontWeight: "600", letterSpacing: -0.2 },
arrow: { paddingHorizontal: 14, paddingVertical: 4 },
arrowText: { color: C.accent, fontSize: 22, fontWeight: "600" },
arrowText: { color: C.accent, fontSize: 24, fontWeight: "500" },
week: { flexDirection: "row" },
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, paddingVertical: 4 },
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, fontWeight: "600", letterSpacing: 0.4, paddingVertical: 6 },
day: {
flex: 1,
aspectRatio: 1.15,
@@ -223,6 +189,6 @@ const s = StyleSheet.create({
margin: 1,
},
daySelected: { backgroundColor: C.accent },
dayToday: { borderWidth: 1, borderColor: C.accent },
dayText: { color: C.text, fontSize: 14 },
dayToday: { backgroundColor: C.accentSoft },
dayText: { color: C.text, fontSize: 15 },
});

View File

@@ -1,56 +1,41 @@
// Preset estimate chips — one tap beats a numeric keyboard. A non-preset
// value (from quick-add, e.g. ~25m) shows up as its own chip so it's never
// invisible or lost.
import { Pressable, StyleSheet, Text, View } from "react-native";
import { View } from "react-native";
import { haptic } from "../lib/haptics";
import { formatEstimate } from "../lib/priority";
import { C } from "../lib/theme";
import { space } from "../lib/theme";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
const PRESETS = [15, 30, 45, 60, 90, 120];
/** Omit `label` when the surrounding panel already titles the group. */
export default function EstimateChips({
value,
onChange,
label,
}: {
value: number | null;
onChange: (v: number | null) => void;
label?: string;
}) {
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
return (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>Estimated time</Text>
<View style={s.rowWrap}>
{options.map((m) => {
const active = value === m;
return (
<Pressable
key={m}
style={[s.chip, active && s.chipActive]}
onPress={() => {
haptic.select();
onChange(active ? null : m);
}}
>
<Text style={[s.chipText, active && { color: C.text }]}>{formatEstimate(m)}</Text>
</Pressable>
);
})}
</View>
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <SectionHeader>{label}</SectionHeader>}
<ChipRow>
{options.map((m) => (
<OptionChip
key={m}
label={formatEstimate(m) ?? ""}
active={value === m}
onPress={() => {
haptic.select();
onChange(value === m ? null : m);
}}
/>
))}
</ChipRow>
</View>
);
}
const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
});

322
app/src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,322 @@
// A tiny geometric icon set drawn from plain Views.
//
// The app ships no vector-icon dependency and emoji render differently on
// every platform (and can't take the accent colour), so these are built from
// borders and rectangles instead: monochrome, tintable, pixel-identical on
// Android and web, and zero bytes of new dependency.
import { StyleSheet, View } from "react-native";
import { C } from "../lib/theme";
export type IconName =
| "today"
| "next"
| "waiting"
| "someday"
| "projects"
| "review"
| "settings"
| "flag"
| "repeat"
| "tag"
| "plus"
| "close"
| "chevron";
type Props = {
name: IconName;
size?: number;
color?: string;
/** Used to carve the crescent out of the "someday" moon. */
bg?: string;
};
export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.bg }: Props) {
const box = { width: size, height: size };
const w = 1.7; // one consistent stroke weight across the whole set
switch (name) {
case "today":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.86,
height: size * 0.82,
borderWidth: w,
borderColor: color,
borderRadius: 5,
overflow: "hidden",
}}
>
<View style={{ height: size * 0.2, backgroundColor: color }} />
<View style={[s.center, { flex: 1 }]}>
<View
style={{ width: w * 2, height: w * 2, borderRadius: 999, backgroundColor: color }}
/>
</View>
</View>
</View>
);
case "next":
return (
<View style={[box, s.center]}>
<View
style={{
width: 0,
height: 0,
borderTopWidth: size * 0.32,
borderBottomWidth: size * 0.32,
borderLeftWidth: size * 0.52,
borderTopColor: "transparent",
borderBottomColor: "transparent",
borderLeftColor: color,
marginLeft: size * 0.12,
}}
/>
</View>
);
case "waiting":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
borderWidth: w,
borderColor: color,
}}
>
{/* hour hand, then minute hand — anchored at the dial centre */}
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.2,
width: w,
height: size * 0.22,
backgroundColor: color,
borderRadius: w,
}}
/>
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.42 - w / 2,
width: size * 0.24,
height: w,
backgroundColor: color,
borderRadius: w,
}}
/>
</View>
</View>
);
case "someday":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: color,
overflow: "hidden",
}}
>
<View
style={{
position: "absolute",
right: -size * 0.22,
top: -size * 0.26,
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: bg,
}}
/>
</View>
</View>
);
case "projects":
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.86, height: size * 0.78 }}>
<View
style={{
width: size * 0.4,
height: size * 0.16,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
backgroundColor: color,
}}
/>
<View
style={{
flex: 1,
borderWidth: w,
borderColor: color,
borderRadius: 5,
borderTopLeftRadius: 0,
}}
/>
</View>
</View>
);
case "review":
return (
<View style={[box, s.center]}>
{/* an open ring plus an arrowhead reads as "cycle" without an SVG path */}
<View
style={{
width: size * 0.82,
height: size * 0.82,
borderRadius: 999,
borderWidth: w,
borderColor: color,
borderTopColor: "transparent",
transform: [{ rotate: "45deg" }],
}}
/>
<View
style={{
position: "absolute",
top: size * 0.06,
right: size * 0.12,
width: 0,
height: 0,
borderLeftWidth: size * 0.16,
borderRightWidth: size * 0.16,
borderBottomWidth: size * 0.2,
borderLeftColor: "transparent",
borderRightColor: "transparent",
borderBottomColor: color,
transform: [{ rotate: "115deg" }],
}}
/>
</View>
);
case "settings":
// Sliders, not a cog: three strokes with offset knobs stays legible at 20px.
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.88, gap: size * 0.16 }}>
{[0.62, 0.3, 0.48].map((knob, i) => (
<View key={i} style={s.sliderRow}>
<View style={{ flex: 1, height: w, backgroundColor: color, borderRadius: w }} />
<View
style={{
position: "absolute",
left: `${knob * 100}%`,
width: w * 3,
height: w * 3,
marginLeft: -w * 1.5,
marginTop: -w,
borderRadius: 999,
borderWidth: w,
borderColor: color,
backgroundColor: bg,
}}
/>
</View>
))}
</View>
</View>
);
case "flag":
return (
<View style={box}>
{/* Pole plus a pennant — reads as a priority flag at 20px where a
detailed banner outline would smear into a blob. */}
<View style={{ position: "absolute", left: size * 0.26, top: size * 0.12, width: w, height: size * 0.76, borderRadius: 1, backgroundColor: color }} />
<View
style={{
position: "absolute",
left: size * 0.26 + w,
top: size * 0.14,
width: size * 0.44,
height: size * 0.34,
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
backgroundColor: color,
}}
/>
</View>
);
case "repeat":
return (
<View style={box}>
{/* Two offset bars with opposing arrowheads: a loop, without the
anti-aliasing mush a thin circular arc gets at this size. */}
<View style={{ position: "absolute", left: size * 0.2, top: size * 0.3, width: size * 0.5, height: w, backgroundColor: color }} />
<View style={{ position: "absolute", left: size * 0.62, top: size * 0.3 - size * 0.09 + w / 2, width: size * 0.18, height: size * 0.18, borderRightWidth: w, borderTopWidth: w, borderColor: color, transform: [{ rotate: "45deg" }] }} />
<View style={{ position: "absolute", left: size * 0.3, top: size * 0.66, width: size * 0.5, height: w, backgroundColor: color }} />
<View style={{ position: "absolute", left: size * 0.2, top: size * 0.66 - size * 0.09 + w / 2, width: size * 0.18, height: size * 0.18, borderLeftWidth: w, borderBottomWidth: w, borderColor: color, transform: [{ rotate: "45deg" }] }} />
</View>
);
case "tag":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.6,
height: size * 0.6,
borderWidth: w,
borderColor: color,
borderRadius: 3,
transform: [{ rotate: "45deg" }],
}}
/>
<View style={{ position: "absolute", left: size * 0.3, top: size * 0.3, width: w + 0.6, height: w + 0.6, borderRadius: 999, backgroundColor: color }} />
</View>
);
case "close":
return (
<View style={[box, s.center]}>
<View style={{ position: "absolute", width: size * 0.6, height: w, borderRadius: 2, backgroundColor: color, transform: [{ rotate: "45deg" }] }} />
<View style={{ position: "absolute", width: size * 0.6, height: w, borderRadius: 2, backgroundColor: color, transform: [{ rotate: "-45deg" }] }} />
</View>
);
case "plus":
return (
<View style={[box, s.center]}>
<View style={{ position: "absolute", width: size * 0.7, height: w + 0.5, borderRadius: 2, backgroundColor: color }} />
<View style={{ position: "absolute", width: w + 0.5, height: size * 0.7, borderRadius: 2, backgroundColor: color }} />
</View>
);
case "chevron":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.34,
height: size * 0.34,
borderRightWidth: w,
borderTopWidth: w,
borderColor: color,
transform: [{ rotate: "45deg" }],
marginLeft: -size * 0.06,
}}
/>
</View>
);
}
}
const s = StyleSheet.create({
center: { alignItems: "center", justifyContent: "center" },
sliderRow: { justifyContent: "center" },
});

View File

@@ -1,56 +1,43 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import { StyleSheet, View } from "react-native";
import { haptic } from "../lib/haptics";
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
import { C } from "../lib/theme";
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
import { C, space } from "../lib/theme";
import type { Task } from "../lib/types";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
/** Omit `label` when the surrounding panel already titles the group. */
export default function PriorityChips({
value,
onChange,
label,
}: {
value: Task["priority"] | null;
onChange: (p: Task["priority"]) => void;
label?: string;
}) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>Priority</Text>
<View style={s.rowWrap}>
{PRIORITIES.map((p) => {
const active = value === p;
const color = PRIORITY_COLOR[p];
return (
<Pressable
key={p}
style={[s.chip, active && { borderColor: color, backgroundColor: `${color}22` }]}
onPress={() => {
haptic.select();
onChange(p);
}}
>
<View style={[s.flag, { backgroundColor: color }]} />
<Text style={[s.text, active && { color: C.text }]}>{PRIORITY_LABEL[p]}</Text>
</Pressable>
);
})}
</View>
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <SectionHeader>{label}</SectionHeader>}
<ChipRow>
{PRIORITIES.map((p) => (
<OptionChip
key={p}
label={PRIORITY_LABEL[p]}
active={value === p}
tone={PRIORITY_COLOR[p]}
tint={PRIORITY_TINT[p]}
leading={<View style={[s.flag, { backgroundColor: PRIORITY_COLOR[p] }]} />}
onPress={() => {
haptic.select();
onChange(p);
}}
/>
))}
</ChipRow>
</View>
);
}
const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 7,
paddingVertical: 8,
paddingHorizontal: 13,
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
flag: { width: 10, height: 10, borderRadius: 5 },
text: { color: C.muted, fontSize: 14 },
flag: { width: 9, height: 9, borderRadius: 999, backgroundColor: C.muted },
});

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,12 +1,14 @@
import { useState } from "react";
import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import WebSortableList from "./WebSortableList";
import { haptic } from "../lib/haptics";
import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { C, radius, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { TaskStatus } from "../lib/types";
import TaskRow from "./TaskRow";
import TaskRow, { TaskSeparator } from "./TaskRow";
import { cardEdges } from "./ui";
// Deferred tasks are hidden from these lists until their date — you can't act
// on them yet, so they shouldn't cost attention. A one-line footer keeps them
@@ -37,6 +39,42 @@ export default function TaskListScreen({
}
const hiddenCount = all.length - tasks.length;
const deferredFooter =
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable
onPress={() => setShowDeferred((v) => !v)}
style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]}
>
<Text style={s.deferredText}>
{showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`}
</Text>
</Pressable>
) : null;
// The native drag library's drop handling is dead on react-native-web, so
// the web build reorders through WebSortableList's grab handles instead.
if (Platform.OS === "web") {
return (
<View style={s.screen}>
<ScrollView contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
{tasks.length === 0 && <Text style={s.empty}>{emptyHint}</Text>}
<WebSortableList
data={tasks}
disabled={archive}
onReorder={(id, to) => reorderTask(id, status, to)}
separator={<TaskSeparator />}
renderItem={(item, i) => (
<View style={cardEdges(i, tasks.length)}>
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} />
</View>
)}
/>
{deferredFooter}
</ScrollView>
</View>
);
}
return (
<View style={s.screen}>
<DraggableFlatList
@@ -50,13 +88,24 @@ export default function TaskListScreen({
reorderTask(data[to].id, status, to);
}
}}
renderItem={({ item, drag, isActive }) => (
<ScaleDecorator activeScale={1.03}>
<View style={isActive && s.activeRow}>
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} />
</View>
</ScaleDecorator>
)}
// The card is drawn by the rows themselves so drag-and-drop still owns
// the whole list: first/last rows round their outer corners, and a
// lifted row goes full-radius so it reads as detached from the stack.
renderItem={({ item, drag, isActive, getIndex }) => {
const i = getIndex() ?? 0;
return (
<ScaleDecorator activeScale={1.02}>
<View style={[...cardEdges(i, tasks.length), isActive && s.lifted]}>
<TaskRow
task={item}
uncheckTo={status === "done" ? "next" : status}
onLongPress={archive ? undefined : drag}
/>
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -69,16 +118,8 @@ export default function TaskListScreen({
/>
}
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
ListFooterComponent={
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable onPress={() => setShowDeferred((v) => !v)}>
<Text style={s.deferredFooter}>
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
</Text>
</Pressable>
) : null
}
contentContainerStyle={{ paddingBottom: 32 }}
ListFooterComponent={deferredFooter}
contentContainerStyle={s.content}
/>
</View>
);
@@ -86,7 +127,16 @@ export default function TaskListScreen({
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 },
lifted: { borderRadius: radius.lg, ...shadow.fab },
empty: {
...type.body,
color: C.muted,
textAlign: "center",
marginTop: 72,
paddingHorizontal: space.xxl,
lineHeight: 22,
},
deferredBtn: { paddingVertical: space.lg, alignItems: "center" },
deferredText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
});

View File

@@ -5,7 +5,8 @@ import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store";
import { C } from "../lib/theme";
import { C, radius, space, type } from "../lib/theme";
import { completeWithUndo } from "../lib/undo";
import type { Task, TaskStatus } from "../lib/types";
export default function TaskRow({
@@ -13,6 +14,7 @@ export default function TaskRow({
uncheckTo = "next",
showStatus,
onLongPress,
emphasized,
}: {
task: Task;
/** Status to revert to when un-checking a done task. */
@@ -20,8 +22,11 @@ export default function TaskRow({
showStatus?: boolean;
/** Drag handle for reorderable lists. */
onLongPress?: () => void;
/** The one thing to do now — bigger, tinted, labelled. */
emphasized?: boolean;
}) {
const router = useRouter();
const done = task.status === "done";
// Due date is rendered separately so it can carry a tone color; the reader
// sees "due today" in amber, never does date math on an ISO string.
const due = task.due_date ? dueMeta(task.due_date) : null;
@@ -37,33 +42,50 @@ export default function TaskRow({
.filter(Boolean)
.join(" · ");
const meta = base || due;
const flag = PRIORITY_COLOR[task.priority];
const toggle = () => {
const done = task.status !== "done";
if (done) haptic.success();
else haptic.tap();
updateTask(task.id, { status: done ? "done" : uncheckTo });
if (!done) {
completeWithUndo(task);
return;
}
haptic.tap();
updateTask(task.id, { status: uncheckTo });
};
return (
<Animated.View entering={FadeIn.duration(150)}>
<Pressable style={s.row} onPress={() => router.push(`/task/${task.id}`)} onLongPress={onLongPress} delayLongPress={200}>
<Pressable
style={({ pressed }) => [s.row, emphasized && s.rowEmphasized, pressed && s.rowPressed]}
onPress={() => router.push(`/task/${task.id}`)}
onLongPress={onLongPress}
delayLongPress={200}
>
{/* Rounded square rather than a circle — the Things checkbox shape.
The border carries the priority colour, so priority is legible at a
glance without spending a whole column on a flag. */}
<Pressable
style={[s.checkbox, { borderColor: PRIORITY_COLOR[task.priority] }]}
style={[
s.checkbox,
emphasized && s.checkboxEmphasized,
{ borderColor: done ? C.done : flag },
done && s.checkboxDone,
]}
hitSlop={10}
onPress={toggle}
>
{task.status === "done" && <View style={s.checkboxFill} />}
{done && <Text style={s.check}>✓</Text>}
</Pressable>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={2}>
{emphasized && <Text style={s.nowLabel}>NOW</Text>}
<Text style={[s.title, emphasized && s.titleEmphasized, done && s.titleDone]} numberOfLines={2}>
{task.title}
</Text>
{!!meta && (
<Text style={s.meta}>
<Text style={s.meta} numberOfLines={1}>
{base}
{!!base && !!due && " · "}
{due && <Text style={{ color: dueColor }}>{due.label}</Text>}
{due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
</Text>
)}
</View>
@@ -76,21 +98,44 @@ const s = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
backgroundColor: C.surface,
},
rowPressed: { backgroundColor: C.surface2 },
rowEmphasized: { backgroundColor: C.accentSoft, paddingVertical: space.lg + 2 },
checkboxEmphasized: { width: 26, height: 26, borderRadius: 8 },
nowLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 1.2, color: C.accent, marginBottom: 2 },
titleEmphasized: { fontSize: 19, fontWeight: "600", lineHeight: 24 },
checkbox: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderRadius: 7,
borderWidth: 1.5,
alignItems: "center",
justifyContent: "center",
},
checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done },
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
checkboxDone: { backgroundColor: C.done },
check: { color: C.onAccent, fontSize: 13, lineHeight: 16, fontWeight: "700" },
title: { ...type.row, color: C.text },
titleDone: { color: C.muted, textDecorationLine: "line-through" },
meta: { ...type.meta, color: C.muted, marginTop: 3 },
});
/** Inset hairline sized to line up under the task title, not the checkbox. */
export const TASK_ROW_INSET = space.lg + 22 + space.md;
export function TaskSeparator() {
return (
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: C.separator,
marginLeft: TASK_ROW_INSET,
// Keeps the hairline from showing through a card's rounded corner.
borderRadius: radius.sm,
}}
/>
);
}

View File

@@ -0,0 +1,98 @@
// One app-wide snackbar, mounted once in the root layout. `showToast` is a
// plain function so stores and rows can call it without a context hop. A new
// toast replaces the current one (last action wins) and the timer restarts.
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { haptic } from "../lib/haptics";
import { C, radius, shadow, space, type } from "../lib/theme";
export interface ToastSpec {
message: string;
action?: { label: string; onPress: () => void };
}
const DURATION_MS = 5000;
// Sits on the home FAB's baseline (index.tsx `fab.bottom`) — above the
// launcher bar, and the right padding leaves the FAB itself uncovered.
const FAB_BOTTOM = 104;
const FAB_CLEARANCE = space.xl + 56 + space.md;
let setCurrent: ((t: ToastSpec | null) => void) | null = null;
export function showToast(spec: ToastSpec) {
setCurrent?.(spec);
}
export function ToastHost() {
const [toast, setToast] = useState<ToastSpec | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => {
setCurrent = setToast;
return () => {
if (setCurrent === setToast) setCurrent = null;
};
}, []);
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), DURATION_MS);
return () => clearTimeout(timer);
}, [toast]);
if (!toast) return null;
const { action } = toast;
return (
<Animated.View
entering={FadeInDown.duration(180)}
exiting={FadeOutDown.duration(150)}
style={[s.wrap, { bottom: FAB_BOTTOM + insets.bottom }]}
pointerEvents="box-none"
>
<Pressable style={s.toast} onPress={() => setToast(null)}>
<Text style={s.message} numberOfLines={1}>
{toast.message}
</Text>
{action && (
<Pressable
hitSlop={12}
onPress={() => {
haptic.tap();
setToast(null);
action.onPress();
}}
style={({ pressed }) => [s.action, pressed && { opacity: 0.6 }]}
>
<Text style={s.actionText}>{action.label}</Text>
</Pressable>
)}
</Pressable>
</Animated.View>
);
}
const s = StyleSheet.create({
wrap: {
position: "absolute",
left: space.lg,
right: FAB_CLEARANCE,
alignItems: "flex-start",
},
toast: {
flexDirection: "row",
alignItems: "center",
gap: space.lg,
maxWidth: 420,
paddingVertical: space.md,
paddingLeft: space.lg,
paddingRight: space.md,
borderRadius: radius.pill,
backgroundColor: C.text,
...shadow.fab,
},
message: { ...type.row, color: C.bg, flexShrink: 1 },
action: { paddingVertical: space.xs, paddingHorizontal: space.sm },
actionText: { ...type.row, color: C.accentSoft, fontWeight: "600" },
});

View File

@@ -0,0 +1,115 @@
// Web-only reorderable list. react-native-draggable-flatlist's drop handling
// never completes on react-native-web (onDragEnd doesn't fire), and the
// PanResponder path is unreliable there too — so each row gets a grab handle
// driven by raw pointer events: pointerdown on the handle, window-level
// move/up listeners, and on release the target slot is computed from measured
// row heights and handed to onReorder (the same store mutation native uses).
import { useState, type ReactNode } from "react";
import { StyleSheet, Text, View, type GestureResponderEvent } from "react-native";
import { C, space } from "../lib/theme";
export default function WebSortableList<T extends { id: string }>({
data,
renderItem,
onReorder,
separator,
disabled,
}: {
data: T[];
renderItem: (item: T, index: number) => ReactNode;
/** Called with the dragged row's id and the index it was dropped at. */
onReorder: (id: string, to: number) => void;
separator?: ReactNode;
disabled?: boolean;
}) {
// Stable mutable array (not a ref: refs may not be read during render).
const [heights] = useState<number[]>([]);
const [drag, setDrag] = useState<{ index: number; dy: number } | null>(null);
const targetIndex = (from: number, dy: number) => {
let idx = from;
let acc = 0;
while (dy > 0 && idx < data.length - 1 && dy > acc + (heights[idx + 1] ?? 48) / 2) {
acc += heights[idx + 1] ?? 48;
idx++;
}
while (dy < 0 && idx > 0 && -dy > acc + (heights[idx - 1] ?? 48) / 2) {
acc += heights[idx - 1] ?? 48;
idx--;
}
return idx;
};
const startDrag = (i: number, e: GestureResponderEvent) => {
if (disabled) return;
// RNW delivers a real PointerEvent; keep it off the row's Pressable.
e.stopPropagation();
e.preventDefault();
const native = e.nativeEvent as unknown as { clientY?: number; pageY: number };
const startY = native.clientY ?? native.pageY;
setDrag({ index: i, dy: 0 });
let dy = 0;
const move = (ev: PointerEvent) => {
dy = ev.clientY - startY;
setDrag({ index: i, dy });
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
setDrag(null);
const to = targetIndex(i, dy);
if (to !== i) onReorder(data[i].id, to);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<View>
{data.map((item, i) => {
const dragging = drag?.index === i;
return (
<View key={item.id}>
{i > 0 && separator}
<View
onLayout={(e) => (heights[i] = e.nativeEvent.layout.height)}
style={dragging && [s.dragging, { transform: [{ translateY: drag.dy }] }]}
>
{renderItem(item, i)}
{!disabled && (
<View onPointerDown={(e) => startDrag(i, e as unknown as GestureResponderEvent)} style={s.handle}>
<Text style={s.handleGlyph}>⠿</Text>
</View>
)}
</View>
</View>
);
})}
</View>
);
}
const s = StyleSheet.create({
dragging: {
zIndex: 10,
elevation: 10,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
handle: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: 44,
alignItems: "center",
justifyContent: "center",
// @ts-expect-error web-only style
cursor: "grab",
touchAction: "none",
userSelect: "none",
},
handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm },
});

View File

@@ -1,15 +1,121 @@
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
import { C } from "../lib/theme";
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type StyleProp, type ViewStyle } from "react-native";
import { C, radius, shadow, space, type } from "../lib/theme";
export function Field({ label, ...props }: TextInputProps & { label: string }) {
/**
* Screen background + consistent gutter. Every screen sits on the grouped
* background so white cards read as raised paper.
*/
export function Screen({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[u.screen, style]}>{children}</View>;
}
/**
* Large in-content heading. Nav-bar titles are tiny and centred; Things puts
* the screen name in the content at display size, which is what gives each
* screen its sense of place.
*/
export function Title({ children, count }: { children: React.ReactNode; count?: number }) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={u.label}>{label}</Text>
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
<View style={u.titleRow}>
<Text style={u.title}>{children}</Text>
{count !== undefined && count > 0 && <Text style={u.titleCount}>{count}</Text>}
</View>
);
}
/** Small caps label above a group. Sentence case — SHOUTING CAPS reads dated. */
export function SectionHeader({ children, tone }: { children: React.ReactNode; tone?: string }) {
return <Text style={[u.sectionHeader, !!tone && { color: tone }]}>{children}</Text>;
}
/**
* Rounded white group. Rows inside are separated by inset hairlines rather
* than full-bleed dividers, so the card keeps a clean silhouette.
*/
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
return <View style={[u.card, style]}>{children}</View>;
}
/**
* Row background plus rounded outer corners for the first and last row of a
* list, so a plain FlatList reads as one card without wrapping it in a `Card`
* (which would break drag-and-drop and section headers).
*/
export function cardEdges(index: number, count: number) {
return [u.cardRow, index === 0 && u.cardTop, index === count - 1 && u.cardBottom];
}
/** Inset hairline for use between rows inside a `Card`. */
export function Separator({ inset = 0 }: { inset?: number }) {
return <View style={[u.separator, { marginLeft: inset }]} />;
}
/** Compact count/status pill. */
export function Pill({ label, tone }: { label: string; tone?: string }) {
return (
<View style={[u.pill, !!tone && { backgroundColor: `${tone}18` }]}>
<Text style={[u.pillText, !!tone && { color: tone }]}>{label}</Text>
</View>
);
}
/** Centred empty state. One quiet line — no illustration, no exclamation. */
export function Empty({ children }: { children: React.ReactNode }) {
return <Text style={u.empty}>{children}</Text>;
}
export function Field({ label, style, ...props }: TextInputProps & { label: string }) {
return (
<View style={{ marginBottom: space.lg }}>
<Text style={u.label}>{label}</Text>
{/* style last so callers can extend the input (e.g. multiline height)
without silently dropping the base styling. */}
<TextInput style={[u.input, style]} placeholderTextColor={C.muted} {...props} />
</View>
);
}
/** One wrapping row of option chips. The single source of chip geometry. */
export function ChipRow({ children }: { children: React.ReactNode }) {
return <View style={u.chipRow}>{children}</View>;
}
/**
* A single selectable chip. `tone` colours the selected border/text and `tint`
* its fill — priority chips pass their flag colour, everything else falls back
* to the app accent.
*/
export function OptionChip({
label,
active,
onPress,
tone = C.accent,
tint = C.accentSoft,
leading,
}: {
label: string;
active?: boolean;
onPress: () => void;
tone?: string;
tint?: string;
leading?: React.ReactNode;
}) {
return (
<Pressable
style={({ pressed }) => [
u.chip,
active && { borderColor: tone, backgroundColor: tint },
pressed && !active && u.chipPressed,
]}
onPress={onPress}
>
{leading}
<Text style={[u.chipText, active && { color: tone, fontWeight: "600" }]}>{label}</Text>
</Pressable>
);
}
/** Labelled chip group. Omit `label` when the surrounding panel already titles it. */
export function Chips<T extends string>({
label,
options,
@@ -17,26 +123,25 @@ export function Chips<T extends string>({
onChange,
labels,
}: {
label: string;
label?: string;
options: T[];
value: T | null;
onChange: (v: T) => void;
labels?: Record<string, string>;
}) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={u.label}>{label}</Text>
<View style={u.chipRow}>
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <Text style={u.label}>{label}</Text>}
<ChipRow>
{options.map((o) => (
<Pressable
<OptionChip
key={o}
style={[u.chip, value === o && u.chipActive]}
label={labels?.[o] ?? o}
active={value === o}
onPress={() => onChange(o)}
>
<Text style={[u.chipText, value === o && { color: C.text }]}>{labels?.[o] ?? o}</Text>
</Pressable>
/>
))}
</View>
</ChipRow>
</View>
);
}
@@ -45,50 +150,119 @@ export function Button({
title,
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={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
style={({ pressed }) => [u.button, variant, style, pressed && { opacity: 0.75 }]}
onPress={onPress}
>
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
<Text style={[u.buttonText, textVariant]}>{title}</Text>
</Pressable>
);
}
const u = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
screen: { flex: 1, backgroundColor: C.bg },
cardRow: { backgroundColor: C.surface },
cardTop: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg },
cardBottom: {
borderBottomLeftRadius: radius.lg,
borderBottomRightRadius: radius.lg,
...shadow.card,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingHorizontal: space.lg,
paddingTop: space.md,
paddingBottom: space.lg,
},
title: { ...type.title, color: C.text },
titleCount: { ...type.title, color: C.muted, fontWeight: "600" },
sectionHeader: {
...type.section,
color: C.textSecondary,
paddingHorizontal: space.xs,
paddingTop: space.xl,
paddingBottom: space.sm,
},
card: {
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
overflow: "hidden",
...shadow.card,
},
separator: { height: StyleSheet.hairlineWidth, backgroundColor: C.separator },
pill: {
paddingHorizontal: space.sm,
paddingVertical: 3,
borderRadius: radius.pill,
backgroundColor: C.surface2,
},
pillText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
empty: { color: C.muted, textAlign: "center", marginTop: 56, ...type.body },
label: {
...type.section,
color: C.textSecondary,
marginBottom: space.sm,
},
input: {
backgroundColor: C.surface,
color: C.text,
borderRadius: 10,
borderWidth: 1,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
padding: 12,
paddingHorizontal: space.md,
paddingVertical: space.md,
fontSize: 16,
},
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
flexDirection: "row",
alignItems: "center",
gap: 7,
paddingVertical: 9,
paddingHorizontal: 15,
borderRadius: radius.pill,
backgroundColor: C.surface,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
chipPressed: { backgroundColor: C.surface2 },
chipText: { ...type.chip, color: C.textSecondary },
button: {
backgroundColor: C.accent,
borderRadius: 10,
padding: 14,
borderRadius: radius.md,
paddingVertical: space.lg - 2,
alignItems: "center",
marginTop: 8,
marginBottom: 12,
marginTop: space.sm,
marginBottom: space.md,
},
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
buttonDanger: { backgroundColor: "transparent", borderWidth: StyleSheet.hairlineWidth, borderColor: C.danger },
buttonSecondary: { backgroundColor: C.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: C.border },
buttonText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
buttonTextDanger: { color: C.danger },
buttonTextSecondary: { color: C.text },
});

View File

@@ -9,11 +9,21 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
4: "Low",
};
// Todoist's flag mapping (urgent red → low grey), retuned to sit calmly on a
// light canvas. `PRIORITY_TINT` is the same hue at wash strength, for the
// selected state of a chip — a saturated fill would shout on a white card.
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
1: "#E05B5B",
2: "#E0A93E",
3: "#5B8DEF",
4: "#6B7280",
1: "#E5484D",
2: "#D98324",
3: "#2F6FED",
4: "#9A9AA2",
};
export const PRIORITY_TINT: Record<Task["priority"], `#${string}`> = {
1: "#FDECEC",
2: "#FBF0E2",
3: "#E8F0FE",
4: "#F0F0F3",
};
export function formatEstimate(min: number | null): string | null {

63
app/src/lib/store.test.ts Normal file
View File

@@ -0,0 +1,63 @@
// Store unit tests — run with `bun test src/lib`. Expo/native modules are
// mocked so the local-first mutations run in plain Bun.
import { beforeEach, describe, expect, mock, test } from "bun:test";
const storage = new Map<string, string>();
mock.module("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (k: string) => storage.get(k) ?? null,
setItem: async (k: string, v: string) => void storage.set(k, v),
},
}));
let seq = 0;
mock.module("expo-crypto", () => ({
randomUUID: () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`,
}));
mock.module("./api", () => ({ api: {} }));
mock.module("./config", () => ({ getConfig: async () => ({ token: "" }) }));
const store = await import("./store");
beforeEach(async () => {
storage.clear();
await store.hydrate();
});
const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!;
describe("completeTask", () => {
test("marks done and undo restores the original status in place", () => {
const a = store.createTask({ title: "a", status: "waiting", sort_order: 5 });
const undo = store.completeTask(a.id);
expect(find(a.id).status).toBe("done");
expect(find(a.id).completed_at).not.toBeNull();
undo!();
expect(find(a.id).status).toBe("waiting");
expect(find(a.id).completed_at).toBeNull();
expect(find(a.id).sort_order).toBe(5);
});
test("revertTo overrides the status undo returns to", () => {
const a = store.createTask({ title: "a", status: "next" });
store.completeTask(a.id, "someday")!();
expect(find(a.id).status).toBe("someday");
});
test("undo of a repeating task trashes the spawned occurrence", () => {
const a = store.createTask({ title: "rent", repeat: "monthly", due_date: "2026-09-01" });
const known = new Set(store.getState().tasks.map((t) => t.id));
const undo = store.completeTask(a.id)!;
const spawned = store.getState().tasks.filter((t) => !known.has(t.id));
expect(spawned).toHaveLength(1);
expect(spawned[0].status).toBe("next");
undo();
expect(find(a.id).status).toBe("next");
expect(find(spawned[0].id).status).toBe("trashed");
});
test("returns null for an already-done or unknown task", () => {
const a = store.createTask({ title: "a", status: "done" });
expect(store.completeTask(a.id)).toBeNull();
expect(store.completeTask("nope")).toBeNull();
});
});

View File

@@ -136,6 +136,11 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
}
export function updateTask(id: string, patch: Partial<Task>) {
applyTaskUpdate(id, patch);
}
/** Applies a patch; returns the next occurrence if completing spawned one. */
function applyTaskUpdate(id: string, patch: Partial<Task>): Task | null {
const before = state.tasks.find((t) => t.id === id);
setState({
tasks: state.tasks.map((t) => {
@@ -152,11 +157,28 @@ export function updateTask(id: string, patch: Partial<Task>) {
// done row later leaves the spawn in place — it's one tap to trash, versus
// silently deleting a row the user may have edited.
if (before && before.repeat && before.status !== "done" && patch.status === "done") {
spawnNextOccurrence(before);
return spawnNextOccurrence(before);
}
return null;
}
function spawnNextOccurrence(t: Task) {
/**
* Complete a task and hand back an undo. Undo puts the task back where it was
* (same status and sort slot) and trashes the occurrence a repeat spawned, so
* a mis-tap leaves no trace. Returns null when there's nothing to complete.
*/
export function completeTask(id: string, revertTo?: Task["status"]): (() => void) | null {
const before = state.tasks.find((t) => t.id === id);
if (!before || before.status === "done") return null;
const spawned = applyTaskUpdate(id, { status: "done" });
const back = revertTo ?? before.status;
return () => {
updateTask(id, { status: back });
if (spawned) trashTask(spawned.id);
};
}
function spawnNextOccurrence(t: Task): Task {
const today = todayIso();
const due = t.due_date ? nextOccurrence(t.due_date, t.repeat!, today) : null;
// Keep the same lead time between defer and due (e.g. "surfaces 3 days before").
@@ -168,7 +190,7 @@ function spawnNextOccurrence(t: Task) {
: t.defer_date
? nextOccurrence(t.defer_date, t.repeat!, today)
: null;
createTask({
return createTask({
title: t.title,
notes: t.notes,
status: t.status,

View File

@@ -1,12 +1,115 @@
export const C = {
bg: "#0F1115",
surface: "#1A1D24",
surface2: "#232733",
border: "#2C313D",
text: "#E6E8EC",
muted: "#8A909C",
accent: "#5B8DEF",
done: "#4CAF7D",
danger: "#E05B5B",
warn: "#E0A93E",
// Design tokens.
//
// The look is modelled on Things 3: a light, calm canvas, generous whitespace,
// hairline inset separators instead of boxes, and exactly one accent colour
// used sparingly. Everything visual in the app resolves through this file —
// no component hard-codes a hex value (priority flags are the one exception,
// and they live in `priority.ts` alongside their labels).
/** Named palette. `dark` is complete and ready to wire to a theme toggle. */
const light = {
/** Screen background behind grouped content. */
bg: "#F7F7F9",
/** Card / row surface that sits on `bg`. */
surface: "#FFFFFF",
/** Inputs, pressed states, subtle fills. */
surface2: "#EFEFF3",
/** Card outline. Deliberately barely-there. */
border: "#E6E6EB",
/** Inset hairline between rows inside a card. */
separator: "#ECECF0",
/** Primary reading colour. Near-black, never pure #000. */
text: "#16161A",
/** Supporting copy that should still be comfortably readable. */
textSecondary: "#6E6E76",
/** Metadata, placeholders, de-emphasised chrome. */
muted: "#9A9AA2",
accent: "#2F6FED",
/** Tinted accent wash for selected chips and active states. */
accentSoft: "#E8F0FE",
done: "#2FA36B",
danger: "#E5484D",
warn: "#D98324",
/** Text that sits on top of `accent`. */
onAccent: "#FFFFFF",
} as const;
const dark = {
bg: "#0C0C0F",
surface: "#17171B",
surface2: "#212127",
border: "#2A2A31",
separator: "#26262C",
text: "#F2F2F5",
textSecondary: "#A6A6B0",
muted: "#7A7A85",
accent: "#4C86F0",
accentSoft: "#1B2740",
done: "#3FBF84",
danger: "#F0605F",
warn: "#E0A93E",
onAccent: "#FFFFFF",
} as const;
export const PALETTES = { light, dark } as const;
/**
* Active palette. Styles are created at module scope (StyleSheet.create) so
* this is a static choice rather than a hook — switching themes at runtime
* would mean moving every stylesheet inside its component.
*/
export const C = light;
/** 4pt spacing grid. Screen gutter is `space.lg`. */
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 28,
} as const;
export const radius = {
sm: 8,
md: 12,
/** Cards and grouped lists. */
lg: 16,
/** Chips and pills. */
pill: 999,
} as const;
/**
* Type scale. `title` is the large in-content screen heading that replaces a
* cramped nav-bar title; `row` is the task title size — 17pt, matching the
* platform body size Things and Todoist both settle on.
*/
export const type = {
title: { fontSize: 30, fontWeight: "700", letterSpacing: -0.6 },
section: { fontSize: 13, fontWeight: "600", letterSpacing: 0.3 },
row: { fontSize: 17, letterSpacing: -0.2 },
body: { fontSize: 15 },
meta: { fontSize: 13 },
chip: { fontSize: 14, fontWeight: "500" },
} as const;
/**
* Card elevation. Kept extremely soft — a Things-like surface reads as paper,
* not as a floating panel.
*/
export const shadow = {
card: {
shadowColor: "#0B0B12",
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
fab: {
shadowColor: "#0B0B12",
shadowOpacity: 0.22,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
} as const;

13
app/src/lib/undo.ts Normal file
View File

@@ -0,0 +1,13 @@
// Complete + "Undo" toast, shared by every checkbox in the app so a mis-tap
// is one tap to take back wherever it happened.
import { showToast } from "../components/Toast";
import { haptic } from "./haptics";
import { completeTask } from "./store";
import type { Task, TaskStatus } from "./types";
export function completeWithUndo(task: Pick<Task, "id" | "title">, revertTo?: TaskStatus) {
const undo = completeTask(task.id, revertTo);
if (!undo) return;
haptic.success();
showToast({ message: `Completed “${task.title}”`, action: { label: "Undo", onPress: undo } });
}

View File

@@ -3,7 +3,7 @@
// Built only from react-native-android-widget primitives (no RN views, no
// hooks) — it renders to Android RemoteViews in a headless task.
//
// Interactions mirror the app: tap the circle to complete, tap the row to
// Interactions mirror the app: tap the checkbox to complete, tap the row to
// open Clarify, tap + to capture, tap anywhere else to open the app.
import { FlexWidget, TextWidget } from "react-native-android-widget";
import { dueMeta, type DueTone } from "../lib/dates";
@@ -31,7 +31,7 @@ function Row({ task }: { task: Task }) {
style={{ width: 32, height: 32, alignItems: "center", justifyContent: "center" }}
>
<FlexWidget
style={{ width: 19, height: 19, borderRadius: 10, borderWidth: 2, borderColor: PRIORITY_COLOR[task.priority] }}
style={{ width: 19, height: 19, borderRadius: 6, borderWidth: 2, borderColor: PRIORITY_COLOR[task.priority] }}
/>
</FlexWidget>
<FlexWidget style={{ flex: 1, marginLeft: 6 }}>
@@ -60,7 +60,7 @@ export function NextUpWidget({ tasks, totalNext, overdueCount }: { tasks: Task[]
style={{ width: "match_parent", flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}
>
<FlexWidget style={{ flexDirection: "row", alignItems: "center" }}>
<TextWidget text="NEXT UP" style={{ fontSize: 12, letterSpacing: 1, color: C.muted }} />
<TextWidget text="Next up" style={{ fontSize: 13, fontWeight: "600", letterSpacing: 0.3, color: C.textSecondary }} />
{totalNext > tasks.length && (
<TextWidget text={` +${totalNext - tasks.length} more`} style={{ fontSize: 12, color: C.muted }} />
)}
@@ -79,7 +79,7 @@ export function NextUpWidget({ tasks, totalNext, overdueCount }: { tasks: Task[]
accessibilityLabel="Add task"
style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: C.accent, alignItems: "center", justifyContent: "center" }}
>
<TextWidget text="+" style={{ fontSize: 16, color: "#FFFFFF" }} />
<TextWidget text="+" style={{ fontSize: 16, color: C.onAccent }} />
</FlexWidget>
</FlexWidget>
{tasks.length === 0 ? (

View File

@@ -16,5 +16,8 @@
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"**/*.test.ts"
]
}