Files
gtd/app/src/components/TaskRow.tsx
2026-09-01 22:39:46 +10:00

126 lines
4.0 KiB
TypeScript

import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
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, radius, space, type } from "../lib/theme";
import type { Task, TaskStatus } from "../lib/types";
export default function TaskRow({
task,
uncheckTo = "next",
showStatus,
onLongPress,
}: {
task: Task;
/** Status to revert to when un-checking a done task. */
uncheckTo?: TaskStatus;
showStatus?: boolean;
/** Drag handle for reorderable lists. */
onLongPress?: () => void;
}) {
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;
const dueColor = due?.tone === "overdue" ? C.danger : due?.tone === "today" ? C.warn : C.muted;
const base = [
showStatus ? task.status : null,
task.repeat && "↻",
task.context,
task.waiting_for && `→ ${task.waiting_for}`,
formatEstimate(task.estimate_min),
isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
]
.filter(Boolean)
.join(" · ");
const meta = base || due;
const flag = PRIORITY_COLOR[task.priority];
const toggle = () => {
const next = task.status !== "done";
if (next) haptic.success();
else haptic.tap();
updateTask(task.id, { status: next ? "done" : uncheckTo });
};
return (
<Animated.View entering={FadeIn.duration(150)}>
<Pressable
style={({ pressed }) => [s.row, 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: done ? C.done : flag }, done && s.checkboxDone]}
hitSlop={10}
onPress={toggle}
>
{done && <Text style={s.check}>✓</Text>}
</Pressable>
<View style={{ flex: 1 }}>
<Text style={[s.title, done && s.titleDone]} numberOfLines={2}>
{task.title}
</Text>
{!!meta && (
<Text style={s.meta} numberOfLines={1}>
{base}
{!!base && !!due && " · "}
{due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
</Text>
)}
</View>
</Pressable>
</Animated.View>
);
}
const s = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
backgroundColor: C.surface,
},
rowPressed: { backgroundColor: C.surface2 },
checkbox: {
width: 22,
height: 22,
borderRadius: 7,
borderWidth: 1.5,
alignItems: "center",
justifyContent: "center",
},
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,
}}
/>
);
}