Inline app/ (was an embedded git repo)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
36
app/src/app/(tabs)/_layout.tsx
Normal file
36
app/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link, Tabs } from "expo-router";
|
||||
import { Text } from "react-native";
|
||||
import { C } from "../../lib/theme";
|
||||
|
||||
function icon(glyph: string) {
|
||||
return ({ focused }: { focused: boolean }) => (
|
||||
<Text style={{ fontSize: 20, opacity: focused ? 1 : 0.45 }}>{glyph}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: C.bg },
|
||||
headerTintColor: C.text,
|
||||
headerShadowVisible: false,
|
||||
sceneStyle: { backgroundColor: C.bg },
|
||||
tabBarStyle: { backgroundColor: C.bg, borderTopColor: C.border },
|
||||
tabBarActiveTintColor: C.text,
|
||||
tabBarInactiveTintColor: C.muted,
|
||||
headerRight: () => (
|
||||
<Link href="/settings" style={{ paddingHorizontal: 16, fontSize: 18 }}>
|
||||
⚙️
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen name="index" options={{ title: "Inbox", tabBarIcon: icon("📥") }} />
|
||||
<Tabs.Screen name="next" options={{ title: "Next", tabBarIcon: icon("▶️") }} />
|
||||
<Tabs.Screen name="waiting" options={{ title: "Waiting", tabBarIcon: icon("⏳") }} />
|
||||
<Tabs.Screen name="someday" options={{ title: "Someday", tabBarIcon: icon("💭") }} />
|
||||
<Tabs.Screen name="projects" options={{ title: "Projects", tabBarIcon: icon("📁") }} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
5
app/src/app/(tabs)/index.tsx
Normal file
5
app/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TaskListScreen from "../../components/TaskListScreen";
|
||||
|
||||
export default function Inbox() {
|
||||
return <TaskListScreen status="inbox" capture emptyHint="Inbox zero. Capture anything above." />;
|
||||
}
|
||||
5
app/src/app/(tabs)/next.tsx
Normal file
5
app/src/app/(tabs)/next.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TaskListScreen from "../../components/TaskListScreen";
|
||||
|
||||
export default function Next() {
|
||||
return <TaskListScreen status="next" capture emptyHint="No next actions. Clarify your inbox." />;
|
||||
}
|
||||
95
app/src/app/(tabs)/projects.tsx
Normal file
95
app/src/app/(tabs)/projects.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { api } from "../../lib/api";
|
||||
import { C } from "../../lib/theme";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
export default function Projects() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setProjects(await api.projects.list());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
const add = async () => {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
setDraft("");
|
||||
await api.projects.create({ name });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
<TextInput
|
||||
style={s.capture}
|
||||
placeholder="New project…"
|
||||
placeholderTextColor={C.muted}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
onSubmitEditing={add}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
<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}
|
||||
{item.open_tasks != null && ` · ${item.open_tasks} open`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: C.muted }}>›</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={s.empty}>No projects yet.</Text>}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
capture: {
|
||||
margin: 12,
|
||||
marginBottom: 4,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: C.surface,
|
||||
color: C.text,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: C.border,
|
||||
},
|
||||
title: { color: C.text, fontSize: 16 },
|
||||
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48 },
|
||||
error: { color: C.danger, marginHorizontal: 16, marginTop: 8 },
|
||||
});
|
||||
5
app/src/app/(tabs)/someday.tsx
Normal file
5
app/src/app/(tabs)/someday.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TaskListScreen from "../../components/TaskListScreen";
|
||||
|
||||
export default function Someday() {
|
||||
return <TaskListScreen status="someday" emptyHint="No someday/maybe items." />;
|
||||
}
|
||||
5
app/src/app/(tabs)/waiting.tsx
Normal file
5
app/src/app/(tabs)/waiting.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TaskListScreen from "../../components/TaskListScreen";
|
||||
|
||||
export default function Waiting() {
|
||||
return <TaskListScreen status="waiting" emptyHint="Not waiting on anyone." />;
|
||||
}
|
||||
24
app/src/app/_layout.tsx
Normal file
24
app/src/app/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { C } from "../lib/theme";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: C.bg },
|
||||
headerTintColor: C.text,
|
||||
headerShadowVisible: false,
|
||||
contentStyle: { backgroundColor: C.bg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} />
|
||||
<Stack.Screen name="project/[id]" options={{ title: "Project" }} />
|
||||
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
122
app/src/app/project/[id].tsx
Normal file
122
app/src/app/project/[id].tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, 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 { C } from "../../lib/theme";
|
||||
import type { Project, ProjectStatus, Task } 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<Project | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
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]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
if (!project) return <Text style={{ color: C.muted, margin: 16 }}>Loading…</Text>;
|
||||
|
||||
const add = async () => {
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||||
<Stack.Screen options={{ title: project.name }} />
|
||||
<View style={{ padding: 16, paddingBottom: 0 }}>
|
||||
<Chips
|
||||
label="Status"
|
||||
options={STATUSES}
|
||||
value={project.status}
|
||||
onChange={async (status) => {
|
||||
setProject({ ...project, status });
|
||||
await api.projects.update(project.id, { status });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
style={s.capture}
|
||||
placeholder="Add next action…"
|
||||
placeholderTextColor={C.muted}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
onSubmitEditing={add}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<FlatList
|
||||
data={tasks}
|
||||
keyExtractor={(t) => t.id}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={s.row} onPress={() => router.push(`/task/${item.id}`)}>
|
||||
<Pressable style={s.checkbox} hitSlop={10} onPress={() => complete(item)} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.title}>{item.title}</Text>
|
||||
<Text style={s.meta}>{[item.status, item.context].filter(Boolean).join(" · ")}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={s.empty}>No open tasks in this project.</Text>}
|
||||
/>
|
||||
<View style={{ padding: 16 }}>
|
||||
<Button
|
||||
title="Drop project"
|
||||
danger
|
||||
onPress={async () => {
|
||||
await api.projects.drop(project.id);
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
capture: {
|
||||
margin: 12,
|
||||
marginBottom: 4,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: C.surface,
|
||||
color: C.text,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: C.border,
|
||||
},
|
||||
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 },
|
||||
});
|
||||
47
app/src/app/settings.tsx
Normal file
47
app/src/app/settings.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, Text } from "react-native";
|
||||
import { Button, Field } from "../components/ui";
|
||||
import { api } from "../lib/api";
|
||||
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
|
||||
import { C } from "../lib/theme";
|
||||
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [token, setToken] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getConfig().then((c) => {
|
||||
setUrl(c.url);
|
||||
setToken(c.token);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
await setConfig(url, token);
|
||||
try {
|
||||
await api.tasks.list({ status: "inbox" });
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setStatus(`Connection failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<Field label="Server" value={url} onChangeText={setUrl} autoCapitalize="none" autoCorrect={false} />
|
||||
<Field
|
||||
label="API token"
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Button title="Save & test" onPress={save} />
|
||||
{status && <Text style={{ color: C.danger }}>{status}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
109
app/src/app/task/[id].tsx
Normal file
109
app/src/app/task/[id].tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, Text } from "react-native";
|
||||
import { Button, Chips, Field } from "../../components/ui";
|
||||
import { api } from "../../lib/api";
|
||||
import { C } from "../../lib/theme";
|
||||
import type { Project, Task, TaskStatus } from "../../lib/types";
|
||||
|
||||
const STATUSES: TaskStatus[] = ["inbox", "next", "waiting", "scheduled", "someday", "done"];
|
||||
|
||||
export default function TaskDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.tasks.get(id), api.projects.list()])
|
||||
.then(([t, ps]) => {
|
||||
setTask(t);
|
||||
setProjects(ps.filter((p) => p.status === "active" || p.id === t.project_id));
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}, [id]);
|
||||
|
||||
if (error) return <Text style={{ color: C.danger, margin: 16 }}>{error}</Text>;
|
||||
if (!task) return <Text style={{ color: C.muted, margin: 16 }}>Loading…</Text>;
|
||||
|
||||
const set = (patch: Partial<Task>) => setTask({ ...task, ...patch });
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.tasks.update(task.id, {
|
||||
title: task.title,
|
||||
notes: task.notes,
|
||||
status: task.status,
|
||||
project_id: task.project_id,
|
||||
context: task.context,
|
||||
waiting_for: task.waiting_for,
|
||||
due_date: task.due_date,
|
||||
defer_date: task.defer_date,
|
||||
});
|
||||
router.back();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const trash = async () => {
|
||||
await api.tasks.trash(task.id);
|
||||
router.back();
|
||||
};
|
||||
|
||||
const projectOptions = ["none", ...projects.map((p) => p.id)];
|
||||
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<Field label="Title" value={task.title} onChangeText={(v) => set({ title: v })} />
|
||||
<Chips label="List" options={STATUSES} value={task.status} onChange={(status) => set({ status })} />
|
||||
<Chips
|
||||
label="Project"
|
||||
options={projectOptions}
|
||||
labels={projectLabels}
|
||||
value={task.project_id ?? "none"}
|
||||
onChange={(v) => set({ project_id: v === "none" ? null : v })}
|
||||
/>
|
||||
<Field
|
||||
label="Context"
|
||||
value={task.context ?? ""}
|
||||
onChangeText={(v) => set({ context: v || null })}
|
||||
placeholder="@home, @computer, @errands…"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{task.status === "waiting" && (
|
||||
<Field
|
||||
label="Waiting for"
|
||||
value={task.waiting_for ?? ""}
|
||||
onChangeText={(v) => set({ waiting_for: v || null })}
|
||||
placeholder="Who or what?"
|
||||
/>
|
||||
)}
|
||||
<Field
|
||||
label="Due date"
|
||||
value={task.due_date ?? ""}
|
||||
onChangeText={(v) => set({ due_date: v || null })}
|
||||
placeholder="YYYY-MM-DD"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Field
|
||||
label="Defer until"
|
||||
value={task.defer_date ?? ""}
|
||||
onChangeText={(v) => set({ defer_date: v || null })}
|
||||
placeholder="YYYY-MM-DD"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Field
|
||||
label="Notes"
|
||||
value={task.notes}
|
||||
onChangeText={(v) => set({ notes: v })}
|
||||
multiline
|
||||
style={{ minHeight: 80, textAlignVertical: "top" }}
|
||||
/>
|
||||
<Button title="Save" onPress={save} />
|
||||
<Button title="Delete" onPress={trash} danger />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
160
app/src/components/TaskListScreen.tsx
Normal file
160
app/src/components/TaskListScreen.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { api } from "../lib/api";
|
||||
import { C } from "../lib/theme";
|
||||
import type { Task, TaskStatus } from "../lib/types";
|
||||
|
||||
function TaskRow({ task, onToggle }: { task: Task; onToggle: (t: Task) => void }) {
|
||||
const router = useRouter();
|
||||
const meta = [task.context, task.waiting_for && `→ ${task.waiting_for}`, task.due_date && `due ${task.due_date}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Pressable style={s.row} onPress={() => router.push(`/task/${task.id}`)}>
|
||||
<Pressable style={s.checkbox} hitSlop={10} onPress={() => onToggle(task)}>
|
||||
{task.status === "done" && <View style={s.checkboxFill} />}
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.title} numberOfLines={2}>
|
||||
{task.title}
|
||||
</Text>
|
||||
{!!meta && <Text style={s.meta}>{meta}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskListScreen({
|
||||
status,
|
||||
capture,
|
||||
emptyHint,
|
||||
}: {
|
||||
status: TaskStatus;
|
||||
capture?: boolean;
|
||||
emptyHint: string;
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setTasks(await api.tasks.list({ status }));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
const add = async () => {
|
||||
const title = draft.trim();
|
||||
if (!title) return;
|
||||
setDraft("");
|
||||
setTasks((cur) => [{ id: `tmp-${title}`, title, status } as Task, ...cur]);
|
||||
try {
|
||||
await api.tasks.create({ title, status });
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (task: Task) => {
|
||||
const done = task.status !== "done";
|
||||
setTasks((cur) => cur.filter((t) => t.id !== task.id));
|
||||
try {
|
||||
await api.tasks.update(task.id, { status: done ? "done" : status });
|
||||
} finally {
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
{capture && (
|
||||
<TextInput
|
||||
style={s.capture}
|
||||
placeholder="Capture anything…"
|
||||
placeholderTextColor={C.muted}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
onSubmitEditing={add}
|
||||
submitBehavior="submit"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
)}
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
<FlatList
|
||||
data={tasks}
|
||||
keyExtractor={(t) => t.id}
|
||||
renderItem={({ item }) => <TaskRow task={item} onToggle={toggle} />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
tintColor={C.muted}
|
||||
onRefresh={async () => {
|
||||
setRefreshing(true);
|
||||
await load();
|
||||
setRefreshing(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
capture: {
|
||||
margin: 12,
|
||||
marginBottom: 4,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: C.surface,
|
||||
color: C.text,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: C.border,
|
||||
},
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderColor: C.muted,
|
||||
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 },
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
||||
error: { color: C.danger, marginHorizontal: 16, marginTop: 8 },
|
||||
});
|
||||
94
app/src/components/ui.tsx
Normal file
94
app/src/components/ui.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
|
||||
import { C } from "../lib/theme";
|
||||
|
||||
export function Field({ label, ...props }: TextInputProps & { label: string }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<Text style={u.label}>{label}</Text>
|
||||
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chips<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
}: {
|
||||
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}>
|
||||
{options.map((o) => (
|
||||
<Pressable
|
||||
key={o}
|
||||
style={[u.chip, value === o && u.chipActive]}
|
||||
onPress={() => onChange(o)}
|
||||
>
|
||||
<Text style={[u.chipText, value === o && { color: C.text }]}>{labels?.[o] ?? o}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
danger,
|
||||
}: {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const u = StyleSheet.create({
|
||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
input: {
|
||||
backgroundColor: C.surface,
|
||||
color: C.text,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
chipRow: { 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 },
|
||||
button: {
|
||||
backgroundColor: C.accent,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
52
app/src/lib/api.ts
Normal file
52
app/src/lib/api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getConfig } from "./config";
|
||||
import type { Project, Task } from "./types";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const { url, token } = await getConfig();
|
||||
if (!token) throw new ApiError(401, "No API token set — open Settings");
|
||||
const res = await fetch(`${url}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new ApiError(res.status, (body as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
tasks: {
|
||||
list: (params: { status?: string; project_id?: string } = {}) => {
|
||||
const q = new URLSearchParams(
|
||||
Object.entries(params).filter(([, v]) => v != null) as [string, string][],
|
||||
).toString();
|
||||
return req<Task[]>(`/v1/tasks${q ? `?${q}` : ""}`);
|
||||
},
|
||||
get: (id: string) => req<Task>(`/v1/tasks/${id}`),
|
||||
create: (data: Partial<Task> & { title: string }) =>
|
||||
req<Task>("/v1/tasks", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<Task>) =>
|
||||
req<Task>(`/v1/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
trash: (id: string) => req<Task>(`/v1/tasks/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
projects: {
|
||||
list: () => req<Project[]>("/v1/projects"),
|
||||
create: (data: Partial<Project> & { name: string }) =>
|
||||
req<Project>("/v1/projects", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: string, data: Partial<Project>) =>
|
||||
req<Project>(`/v1/projects/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
drop: (id: string) => req<Project>(`/v1/projects/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
contexts: { list: () => req<string[]>("/v1/contexts") },
|
||||
};
|
||||
24
app/src/lib/config.ts
Normal file
24
app/src/lib/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
export const DEFAULT_URL = "https://gtd.rehbock.xyz/api";
|
||||
|
||||
let cached: { url: string; token: string } | null = null;
|
||||
|
||||
export async function getConfig() {
|
||||
if (!cached) {
|
||||
const [url, token] = await Promise.all([
|
||||
AsyncStorage.getItem("api_url"),
|
||||
AsyncStorage.getItem("api_token"),
|
||||
]);
|
||||
cached = { url: url || DEFAULT_URL, token: token || "" };
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function setConfig(url: string, token: string) {
|
||||
cached = { url: url.trim().replace(/\/$/, "") || DEFAULT_URL, token: token.trim() };
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem("api_url", cached.url),
|
||||
AsyncStorage.setItem("api_token", cached.token),
|
||||
]);
|
||||
}
|
||||
11
app/src/lib/theme.ts
Normal file
11
app/src/lib/theme.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const C = {
|
||||
bg: "#0F1115",
|
||||
surface: "#1A1D24",
|
||||
surface2: "#232733",
|
||||
border: "#2C313D",
|
||||
text: "#E6E8EC",
|
||||
muted: "#8A909C",
|
||||
accent: "#5B8DEF",
|
||||
done: "#4CAF7D",
|
||||
danger: "#E05B5B",
|
||||
};
|
||||
29
app/src/lib/types.ts
Normal file
29
app/src/lib/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
|
||||
export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
notes: string;
|
||||
status: TaskStatus;
|
||||
project_id: string | null;
|
||||
context: string | null;
|
||||
waiting_for: string | null;
|
||||
due_date: string | null;
|
||||
defer_date: string | null;
|
||||
completed_at: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ProjectStatus;
|
||||
notes: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
open_tasks?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user