diff --git a/app/package-lock.json b/app/package-lock.json index f6f64ed..bf0c3aa 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", + "expo-crypto": "~57.0.1", "expo-device": "~57.0.1", "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", @@ -3713,6 +3714,15 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz", + "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-device": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz", diff --git a/app/package.json b/app/package.json index a73053f..efb79b6 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "expo": "~57.0.11", "expo-constants": "~57.0.9", + "expo-crypto": "~57.0.1", "expo-device": "~57.0.1", "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx index 57cac87..539275a 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -1,8 +1,20 @@ import { Link, Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import { useEffect } from "react"; +import { AppState } from "react-native"; +import { hydrate, sync } from "../lib/store"; import { C } from "../lib/theme"; export default function RootLayout() { + useEffect(() => { + void hydrate(); + // Re-sync whenever the app returns to the foreground. + const sub = AppState.addEventListener("change", (s) => { + if (s === "active") void sync(); + }); + return () => sub.remove(); + }, []); + return ( <> diff --git a/app/src/app/index.tsx b/app/src/app/index.tsx index a1092a4..c9c10d2 100644 --- a/app/src/app/index.tsx +++ b/app/src/app/index.tsx @@ -1,9 +1,9 @@ -import { useFocusEffect, useRouter } from "expo-router"; -import { useCallback, useState } from "react"; +import { useRouter } from "expo-router"; +import { useState } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; -import { api } from "../lib/api"; +import { activeProjects, createTask, tasksByStatus } from "../lib/store"; import { C } from "../lib/theme"; -import type { Project, Task } from "../lib/types"; +import { useStore } from "../lib/useStore"; const Q = { next: { title: "Next up", color: "#3DBF77", bg: "#15301F", border: "#1E4D33" }, @@ -25,10 +25,7 @@ function Quadrant({ }) { const q = Q[kind]; return ( - + {q.title} {count} @@ -44,42 +41,21 @@ function Quadrant({ export default function Home() { const router = useRouter(); - const [tasks, setTasks] = useState([]); - const [projects, setProjects] = useState([]); + const store = useStore(); const [draft, setDraft] = useState(""); - const [error, setError] = useState(null); - const load = useCallback(async () => { - try { - const [ts, ps] = await Promise.all([api.tasks.list(), api.projects.list()]); - setTasks(ts); - setProjects(ps.filter((p) => p.status === "active")); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }, []); - - useFocusEffect( - useCallback(() => { - load(); - }, [load]), - ); - - const capture = async () => { + const capture = () => { const title = draft.trim(); if (!title) return; setDraft(""); - try { - await api.tasks.create({ title }); - } finally { - load(); - } + createTask({ title }); }; - const byStatus = (st: string) => tasks.filter((t) => t.status === st); - const inboxCount = byStatus("inbox").length; - const titles = (st: string) => byStatus(st).slice(0, 3).map((t) => t.title); + const inbox = tasksByStatus(store, "inbox"); + const next = tasksByStatus(store, "next"); + const waiting = tasksByStatus(store, "waiting"); + const someday = tasksByStatus(store, "someday"); + const projects = activeProjects(store).filter((p) => p.status === "active"); return ( @@ -95,22 +71,20 @@ export default function Home() { /> router.push("/list/inbox")}> 📥 Inbox - {inboxCount} + + {store.pending > 0 && ⇅ {store.pending}} + {inbox.length} + - {error && {error}} + {store.syncError && {store.syncError}} - router.push("/list/next")} /> - router.push("/list/waiting")} /> + t.title)} onPress={() => router.push("/list/next")} /> + t.title)} onPress={() => router.push("/list/waiting")} /> - router.push("/list/someday")} /> - p.name)} - onPress={() => router.push("/projects")} - /> + t.title)} onPress={() => router.push("/list/someday")} /> + p.name)} onPress={() => router.push("/projects")} /> @@ -141,14 +115,10 @@ const s = StyleSheet.create({ borderColor: C.border, }, inboxText: { color: C.text, fontSize: 16, fontWeight: "600" }, + pendingDot: { color: C.muted, fontSize: 13 }, grid: { flex: 1, marginTop: 10, gap: 10 }, gridRow: { flex: 1, flexDirection: "row", gap: 10 }, - quad: { - flex: 1, - borderRadius: 16, - borderWidth: 1, - padding: 14, - }, + quad: { flex: 1, borderRadius: 16, borderWidth: 1, padding: 14 }, quadHead: { flexDirection: "row", justifyContent: "space-between", marginBottom: 8 }, quadTitle: { fontSize: 17, fontWeight: "700" }, quadCount: { fontSize: 17, fontWeight: "700" }, diff --git a/app/src/app/project/[id].tsx b/app/src/app/project/[id].tsx index a5dc194..2127753 100644 --- a/app/src/app/project/[id].tsx +++ b/app/src/app/project/[id].tsx @@ -1,46 +1,32 @@ -import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useState } from "react"; +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 { api } from "../../lib/api"; +import { createTask, dropProject, updateProject, updateTask } from "../../lib/store"; import { C } from "../../lib/theme"; -import type { Project, ProjectStatus, Task } from "../../lib/types"; +import { useStore } from "../../lib/useStore"; +import type { ProjectStatus } from "../../lib/types"; const STATUSES: ProjectStatus[] = ["active", "someday", "completed"]; export default function ProjectDetail() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); - const [project, setProject] = useState(null); - const [tasks, setTasks] = useState([]); + const store = useStore(); const [draft, setDraft] = useState(""); - const load = useCallback(async () => { - const [ps, ts] = await Promise.all([api.projects.list(), api.tasks.list({ project_id: id })]); - setProject(ps.find((p) => p.id === id) ?? null); - setTasks(ts.filter((t) => t.status !== "done")); - }, [id]); + const project = store.projects.find((p) => p.id === id); + if (!project) return Project not found.; - useFocusEffect( - useCallback(() => { - load(); - }, [load]), - ); + const tasks = store.tasks + .filter((t) => t.project_id === id && t.status !== "done" && t.status !== "trashed") + .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at)); - if (!project) return Loading…; - - const add = async () => { + const add = () => { const title = draft.trim(); if (!title) return; setDraft(""); - await api.tasks.create({ title, status: "next", project_id: id }); - load(); - }; - - const complete = async (t: Task) => { - setTasks((cur) => cur.filter((x) => x.id !== t.id)); - await api.tasks.update(t.id, { status: "done" }); - load(); + createTask({ title, status: "next", project_id: id }); }; return ( @@ -51,10 +37,7 @@ export default function ProjectDetail() { label="Status" options={STATUSES} value={project.status} - onChange={async (status) => { - setProject({ ...project, status }); - await api.projects.update(project.id, { status }); - }} + onChange={(status) => updateProject(project.id, { status })} /> t.id} renderItem={({ item }) => ( router.push(`/task/${item.id}`)}> - complete(item)} /> + updateTask(item.id, { status: "done" })} + /> {item.title} {[item.status, item.context].filter(Boolean).join(" · ")} @@ -84,8 +72,8 @@ export default function ProjectDetail() {