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>
This commit is contained in:
@@ -2,10 +2,11 @@ import NetInfo from "@react-native-community/netinfo";
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AppState } from "react-native";
|
import { AppState, Pressable } from "react-native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { getState, hydrate, subscribe, sync } from "../lib/store";
|
import { getState, hydrate, subscribe, sync } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import Icon from "../components/Icon";
|
||||||
|
import { C, space } from "../lib/theme";
|
||||||
import { refreshNextUpWidget } from "../widget/refresh";
|
import { refreshNextUpWidget } from "../widget/refresh";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
@@ -35,12 +36,16 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="dark" />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: C.bg },
|
headerStyle: { backgroundColor: C.bg },
|
||||||
headerTintColor: C.text,
|
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,
|
headerShadowVisible: false,
|
||||||
|
headerTitleStyle: { fontSize: 17, fontWeight: "600", color: C.text, letterSpacing: -0.2 },
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
contentStyle: { backgroundColor: C.bg },
|
contentStyle: { backgroundColor: C.bg },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -49,8 +54,13 @@ export default function RootLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "GTD",
|
title: "GTD",
|
||||||
headerRight: () => (
|
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>
|
</Link>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import TaskRow from "../components/TaskRow";
|
import Icon, { type IconName } from "../components/Icon";
|
||||||
|
import TaskRow, { TaskSeparator } from "../components/TaskRow";
|
||||||
|
import { Card, Empty, Screen, SectionHeader, Title } from "../components/ui";
|
||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { daysSince, useLastReview } from "../lib/review";
|
import { daysSince, useLastReview } from "../lib/review";
|
||||||
import { todayTasks, visibleTasksByStatus } from "../lib/store";
|
import { todayTasks, visibleTasksByStatus } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { C, shadow, space, type } from "../lib/theme";
|
||||||
import { useStore } from "../lib/useStore";
|
import { useStore } from "../lib/useStore";
|
||||||
|
|
||||||
const MENU = [
|
const MENU: { icon: IconName; label: string; href: string }[] = [
|
||||||
{ icon: "📅", label: "Today", href: "/today" },
|
{ icon: "today", label: "Today", href: "/today" },
|
||||||
{ icon: "▶️", label: "Next", href: "/list/next" },
|
{ icon: "next", label: "Next", href: "/list/next" },
|
||||||
{ icon: "⏳", label: "Waiting", href: "/list/waiting" },
|
{ icon: "waiting", label: "Waiting", href: "/list/waiting" },
|
||||||
{ icon: "💭", label: "Someday", href: "/list/someday" },
|
{ icon: "someday", label: "Someday", href: "/list/someday" },
|
||||||
{ icon: "📁", label: "Projects", href: "/projects" },
|
{ icon: "projects", label: "Projects", href: "/projects" },
|
||||||
{ icon: "🔄", label: "Review", href: "/review" },
|
{ icon: "review", label: "Review", href: "/review" },
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
|
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() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,103 +50,146 @@ export default function Home() {
|
|||||||
useFocusEffect(reloadReview);
|
useFocusEffect(reloadReview);
|
||||||
const reviewDays = daysSince(lastReview);
|
const reviewDays = daysSince(lastReview);
|
||||||
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
|
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
|
||||||
|
const preview = next.slice(0, PREVIEW);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.screen}>
|
<Screen>
|
||||||
<View style={{ flex: 1 }}>
|
<ScrollView contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
|
||||||
<Text style={s.sectionTitle}>Next up</Text>
|
<Title count={next.length}>Next up</Title>
|
||||||
{next.slice(0, 3).map((t) => (
|
|
||||||
<TaskRow key={t.id} task={t} />
|
{preview.length === 0 ? (
|
||||||
))}
|
<Empty>Nothing queued. Add the next thing on your mind.</Empty>
|
||||||
{next.length === 0 && <Text style={s.empty}>Nothing next. Tap + to add.</Text>}
|
) : (
|
||||||
{next.length > 3 && (
|
<Card>
|
||||||
<Pressable onPress={() => router.push("/list/next")}>
|
{preview.map((t, i) => (
|
||||||
<Text style={s.viewAll}>all next actions ({next.length}) →</Text>
|
<View key={t.id}>
|
||||||
|
{i > 0 && <TaskSeparator />}
|
||||||
|
<TaskRow task={t} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{overdue.length > 0 && (
|
|
||||||
<Pressable onPress={() => router.push("/today")}>
|
{(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
|
||||||
<Text style={s.overdue}>{overdue.length} overdue →</Text>
|
<>
|
||||||
</Pressable>
|
<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 && (
|
</ScrollView>
|
||||||
<Pressable onPress={() => router.push("/review")}>
|
|
||||||
<Text style={s.reviewNudge}>
|
<View style={s.tabBar}>
|
||||||
{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}>
|
|
||||||
{MENU.map((m) => (
|
{MENU.map((m) => (
|
||||||
<Pressable key={m.label} style={s.menuItem} onPress={() => router.push(m.href)}>
|
<Pressable
|
||||||
<Text style={s.menuIcon}>{m.icon}</Text>
|
key={m.label}
|
||||||
<Text style={s.menuLabel}>{m.label}</Text>
|
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>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={s.fab}
|
style={({ pressed }) => [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
haptic.tap();
|
haptic.tap();
|
||||||
router.push("/add");
|
router.push("/add");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={s.fabText}>+</Text>
|
<Icon name="plus" size={26} color={C.onAccent} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg },
|
content: { paddingHorizontal: space.lg, paddingBottom: 120 },
|
||||||
sectionTitle: {
|
|
||||||
color: C.muted,
|
viewAll: {
|
||||||
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: {
|
|
||||||
flexDirection: "row",
|
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,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: C.border,
|
borderTopColor: C.border,
|
||||||
paddingTop: 10,
|
paddingTop: space.md,
|
||||||
paddingBottom: 18,
|
paddingBottom: space.xxl,
|
||||||
backgroundColor: C.bg,
|
|
||||||
},
|
},
|
||||||
menuItem: { flex: 1, alignItems: "center", gap: 3 },
|
tab: { flex: 1, alignItems: "center", gap: 5 },
|
||||||
menuIcon: { fontSize: 20 },
|
tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
|
||||||
menuLabel: { color: C.muted, fontSize: 11 },
|
|
||||||
fab: {
|
fab: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 20,
|
right: space.xl,
|
||||||
bottom: 92,
|
bottom: 104,
|
||||||
width: 58,
|
width: 56,
|
||||||
height: 58,
|
height: 56,
|
||||||
borderRadius: 29,
|
borderRadius: 28,
|
||||||
backgroundColor: C.accent,
|
backgroundColor: C.accent,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
elevation: 6,
|
...shadow.fab,
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.35,
|
|
||||||
shadowRadius: 8,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
},
|
},
|
||||||
fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { C } from "../lib/theme";
|
import { C, radius, space, type } from "../lib/theme";
|
||||||
|
import { SectionHeader } from "./ui";
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
|
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||||
@@ -97,8 +98,8 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
s.dayText,
|
s.dayText,
|
||||||
!inMonth && { color: C.border },
|
!inMonth && { color: C.muted, opacity: 0.45 },
|
||||||
selected && { color: "#FFFFFF", fontWeight: "700" },
|
selected && { color: C.onAccent, fontWeight: "700" },
|
||||||
!selected && isToday && { color: C.accent },
|
!selected && isToday && { color: C.accent },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -133,9 +134,9 @@ export default function DateField({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={{ marginBottom: space.lg }}>
|
||||||
<View style={s.labelRow}>
|
<View style={s.labelRow}>
|
||||||
<Text style={s.label}>{label}</Text>
|
<SectionHeader>{label}</SectionHeader>
|
||||||
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
|
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
|
||||||
</View>
|
</View>
|
||||||
<View style={s.rowWrap}>
|
<View style={s.rowWrap}>
|
||||||
@@ -148,7 +149,7 @@ export default function DateField({
|
|||||||
onChange(value === q.v && clearable ? null : q.v);
|
onChange(value === q.v && clearable ? null : q.v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[s.chipText, value === q.v && { color: C.text }]}>{q.label}</Text>
|
<Text style={[s.chipText, value === q.v && s.chipTextActive]}>{q.label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -158,7 +159,7 @@ export default function DateField({
|
|||||||
setOpen(!open);
|
setOpen(!open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[s.chipText, open && { color: C.text }]}>📆 Pick…</Text>
|
<Text style={[s.chipText, open && s.chipTextActive]}>Pick a date…</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{clearable && value != null && (
|
{clearable && value != null && (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -186,34 +187,34 @@ export default function DateField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 },
|
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||||
label: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 },
|
chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
|
||||||
chosen: { color: C.accent, fontSize: 14, fontWeight: "600" },
|
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
||||||
chip: {
|
chip: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 9,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 15,
|
||||||
borderRadius: 20,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
},
|
},
|
||||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||||
chipText: { color: C.muted, fontSize: 14 },
|
chipText: { ...type.chip, color: C.textSecondary },
|
||||||
|
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||||
calendar: {
|
calendar: {
|
||||||
marginTop: 10,
|
marginTop: space.md,
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
borderRadius: 14,
|
borderRadius: radius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
padding: 10,
|
padding: space.md,
|
||||||
},
|
},
|
||||||
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 6 },
|
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: space.sm },
|
||||||
calTitle: { color: C.text, fontSize: 15, fontWeight: "600" },
|
calTitle: { color: C.text, fontSize: 16, fontWeight: "600", letterSpacing: -0.2 },
|
||||||
arrow: { paddingHorizontal: 14, paddingVertical: 4 },
|
arrow: { paddingHorizontal: 14, paddingVertical: 4 },
|
||||||
arrowText: { color: C.accent, fontSize: 22, fontWeight: "600" },
|
arrowText: { color: C.accent, fontSize: 24, fontWeight: "500" },
|
||||||
week: { flexDirection: "row" },
|
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: {
|
day: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
aspectRatio: 1.15,
|
aspectRatio: 1.15,
|
||||||
@@ -223,6 +224,6 @@ const s = StyleSheet.create({
|
|||||||
margin: 1,
|
margin: 1,
|
||||||
},
|
},
|
||||||
daySelected: { backgroundColor: C.accent },
|
daySelected: { backgroundColor: C.accent },
|
||||||
dayToday: { borderWidth: 1, borderColor: C.accent },
|
dayToday: { backgroundColor: C.accentSoft },
|
||||||
dayText: { color: C.text, fontSize: 14 },
|
dayText: { color: C.text, fontSize: 15 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { formatEstimate } from "../lib/priority";
|
import { formatEstimate } from "../lib/priority";
|
||||||
import { C } from "../lib/theme";
|
import { C, radius, space, type } from "../lib/theme";
|
||||||
|
import { SectionHeader } from "./ui";
|
||||||
|
|
||||||
const PRESETS = [15, 30, 45, 60, 90, 120];
|
const PRESETS = [15, 30, 45, 60, 90, 120];
|
||||||
|
|
||||||
@@ -17,21 +18,25 @@ export default function EstimateChips({
|
|||||||
}) {
|
}) {
|
||||||
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
|
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={s.wrap}>
|
||||||
<Text style={s.label}>Estimated time</Text>
|
<SectionHeader>Estimated time</SectionHeader>
|
||||||
<View style={s.rowWrap}>
|
<View style={s.rowWrap}>
|
||||||
{options.map((m) => {
|
{options.map((m) => {
|
||||||
const active = value === m;
|
const active = value === m;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={m}
|
key={m}
|
||||||
style={[s.chip, active && s.chipActive]}
|
style={({ pressed }) => [
|
||||||
|
s.chip,
|
||||||
|
active && s.chipActive,
|
||||||
|
pressed && !active && { backgroundColor: C.surface2 },
|
||||||
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
haptic.select();
|
haptic.select();
|
||||||
onChange(active ? null : m);
|
onChange(active ? null : m);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[s.chipText, active && { color: C.text }]}>{formatEstimate(m)}</Text>
|
<Text style={[s.chipText, active && s.chipTextActive]}>{formatEstimate(m)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -41,16 +46,17 @@ export default function EstimateChips({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
wrap: { marginBottom: space.lg },
|
||||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||||
chip: {
|
chip: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 9,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 15,
|
||||||
borderRadius: 20,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
},
|
},
|
||||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||||
chipText: { color: C.muted, fontSize: 14 },
|
chipText: { ...type.chip, color: C.textSecondary },
|
||||||
|
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||||
});
|
});
|
||||||
|
|||||||
260
app/src/components/Icon.tsx
Normal file
260
app/src/components/Icon.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// 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"
|
||||||
|
| "plus"
|
||||||
|
| "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 "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" },
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
|
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
|
||||||
import { C } from "../lib/theme";
|
import { C, radius, space, type } from "../lib/theme";
|
||||||
import type { Task } from "../lib/types";
|
import type { Task } from "../lib/types";
|
||||||
|
import { SectionHeader } from "./ui";
|
||||||
|
|
||||||
export default function PriorityChips({
|
export default function PriorityChips({
|
||||||
value,
|
value,
|
||||||
@@ -12,8 +13,8 @@ export default function PriorityChips({
|
|||||||
onChange: (p: Task["priority"]) => void;
|
onChange: (p: Task["priority"]) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={s.wrap}>
|
||||||
<Text style={s.label}>Priority</Text>
|
<SectionHeader>Priority</SectionHeader>
|
||||||
<View style={s.rowWrap}>
|
<View style={s.rowWrap}>
|
||||||
{PRIORITIES.map((p) => {
|
{PRIORITIES.map((p) => {
|
||||||
const active = value === p;
|
const active = value === p;
|
||||||
@@ -21,14 +22,20 @@ export default function PriorityChips({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={p}
|
key={p}
|
||||||
style={[s.chip, active && { borderColor: color, backgroundColor: `${color}22` }]}
|
style={({ pressed }) => [
|
||||||
|
s.chip,
|
||||||
|
active && { borderColor: color, backgroundColor: PRIORITY_TINT[p] },
|
||||||
|
pressed && !active && { backgroundColor: C.surface2 },
|
||||||
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
haptic.select();
|
haptic.select();
|
||||||
onChange(p);
|
onChange(p);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={[s.flag, { backgroundColor: color }]} />
|
<View style={[s.flag, { backgroundColor: color }]} />
|
||||||
<Text style={[s.text, active && { color: C.text }]}>{PRIORITY_LABEL[p]}</Text>
|
<Text style={[s.text, active && { color, fontWeight: "600" }]}>
|
||||||
|
{PRIORITY_LABEL[p]}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -38,19 +45,19 @@ export default function PriorityChips({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
wrap: { marginBottom: space.lg },
|
||||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 7,
|
gap: 7,
|
||||||
paddingVertical: 8,
|
paddingVertical: 9,
|
||||||
paddingHorizontal: 13,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 20,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
},
|
},
|
||||||
flag: { width: 10, height: 10, borderRadius: 5 },
|
flag: { width: 9, height: 9, borderRadius: 999 },
|
||||||
text: { color: C.muted, fontSize: 14 },
|
text: { ...type.chip, color: C.textSecondary },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native"
|
|||||||
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
|
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
|
||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
|
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 { useStore } from "../lib/useStore";
|
||||||
import type { TaskStatus } from "../lib/types";
|
import type { TaskStatus } from "../lib/types";
|
||||||
import TaskRow from "./TaskRow";
|
import TaskRow, { TaskSeparator } from "./TaskRow";
|
||||||
|
|
||||||
// Deferred tasks are hidden from these lists until their date — you can't act
|
// 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
|
// on them yet, so they shouldn't cost attention. A one-line footer keeps them
|
||||||
@@ -50,13 +50,31 @@ export default function TaskListScreen({
|
|||||||
reorderTask(data[to].id, status, to);
|
reorderTask(data[to].id, status, to);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
renderItem={({ item, drag, isActive }) => (
|
// The card is drawn by the rows themselves so drag-and-drop still owns
|
||||||
<ScaleDecorator activeScale={1.03}>
|
// the whole list: first/last rows round their outer corners, and a
|
||||||
<View style={isActive && s.activeRow}>
|
// lifted row goes full-radius so it reads as detached from the stack.
|
||||||
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} />
|
renderItem={({ item, drag, isActive, getIndex }) => {
|
||||||
</View>
|
const i = getIndex() ?? 0;
|
||||||
</ScaleDecorator>
|
return (
|
||||||
)}
|
<ScaleDecorator activeScale={1.02}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
s.row,
|
||||||
|
i === 0 && s.first,
|
||||||
|
i === tasks.length - 1 && s.last,
|
||||||
|
isActive && s.lifted,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TaskRow
|
||||||
|
task={item}
|
||||||
|
uncheckTo={status === "done" ? "next" : status}
|
||||||
|
onLongPress={archive ? undefined : drag}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={TaskSeparator}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -71,14 +89,17 @@ export default function TaskListScreen({
|
|||||||
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
|
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
|
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
|
||||||
<Pressable onPress={() => setShowDeferred((v) => !v)}>
|
<Pressable
|
||||||
<Text style={s.deferredFooter}>
|
onPress={() => setShowDeferred((v) => !v)}
|
||||||
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
|
style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]}
|
||||||
|
>
|
||||||
|
<Text style={s.deferredText}>
|
||||||
|
{showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
contentContainerStyle={{ paddingBottom: 32 }}
|
contentContainerStyle={s.content}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -86,7 +107,23 @@ export default function TaskListScreen({
|
|||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg },
|
screen: { flex: 1, backgroundColor: C.bg },
|
||||||
activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
|
content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 },
|
||||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
row: { backgroundColor: C.surface },
|
||||||
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
|
first: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg },
|
||||||
|
last: {
|
||||||
|
borderBottomLeftRadius: radius.lg,
|
||||||
|
borderBottomRightRadius: radius.lg,
|
||||||
|
...shadow.card,
|
||||||
|
},
|
||||||
|
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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
|
|||||||
import { haptic } from "../lib/haptics";
|
import { haptic } from "../lib/haptics";
|
||||||
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
|
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
|
||||||
import { updateTask } from "../lib/store";
|
import { updateTask } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { C, radius, space, type } from "../lib/theme";
|
||||||
import type { Task, TaskStatus } from "../lib/types";
|
import type { Task, TaskStatus } from "../lib/types";
|
||||||
|
|
||||||
export default function TaskRow({
|
export default function TaskRow({
|
||||||
@@ -22,6 +22,7 @@ export default function TaskRow({
|
|||||||
onLongPress?: () => void;
|
onLongPress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const done = task.status === "done";
|
||||||
// Due date is rendered separately so it can carry a tone color; the reader
|
// 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.
|
// sees "due today" in amber, never does date math on an ISO string.
|
||||||
const due = task.due_date ? dueMeta(task.due_date) : null;
|
const due = task.due_date ? dueMeta(task.due_date) : null;
|
||||||
@@ -37,33 +38,42 @@ export default function TaskRow({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
const meta = base || due;
|
const meta = base || due;
|
||||||
|
const flag = PRIORITY_COLOR[task.priority];
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
const done = task.status !== "done";
|
const next = task.status !== "done";
|
||||||
if (done) haptic.success();
|
if (next) haptic.success();
|
||||||
else haptic.tap();
|
else haptic.tap();
|
||||||
updateTask(task.id, { status: done ? "done" : uncheckTo });
|
updateTask(task.id, { status: next ? "done" : uncheckTo });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeIn.duration(150)}>
|
<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, 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
|
<Pressable
|
||||||
style={[s.checkbox, { borderColor: PRIORITY_COLOR[task.priority] }]}
|
style={[s.checkbox, { borderColor: done ? C.done : flag }, done && s.checkboxDone]}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
onPress={toggle}
|
onPress={toggle}
|
||||||
>
|
>
|
||||||
{task.status === "done" && <View style={s.checkboxFill} />}
|
{done && <Text style={s.check}>✓</Text>}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={s.title} numberOfLines={2}>
|
<Text style={[s.title, done && s.titleDone]} numberOfLines={2}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</Text>
|
</Text>
|
||||||
{!!meta && (
|
{!!meta && (
|
||||||
<Text style={s.meta}>
|
<Text style={s.meta} numberOfLines={1}>
|
||||||
{base}
|
{base}
|
||||||
{!!base && !!due && " · "}
|
{!!base && !!due && " · "}
|
||||||
{due && <Text style={{ color: dueColor }}>{due.label}</Text>}
|
{due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -76,21 +86,40 @@ const s = StyleSheet.create({
|
|||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: space.md,
|
||||||
paddingVertical: 14,
|
paddingVertical: space.md + 2,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: space.lg,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
backgroundColor: C.surface,
|
||||||
borderBottomColor: C.border,
|
|
||||||
},
|
},
|
||||||
|
rowPressed: { backgroundColor: C.surface2 },
|
||||||
checkbox: {
|
checkbox: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
borderRadius: 11,
|
borderRadius: 7,
|
||||||
borderWidth: 2,
|
borderWidth: 1.5,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done },
|
checkboxDone: { backgroundColor: C.done },
|
||||||
title: { color: C.text, fontSize: 16 },
|
check: { color: "#FFFFFF", fontSize: 13, lineHeight: 16, fontWeight: "700" },
|
||||||
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
|
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,63 @@
|
|||||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
|
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type ViewStyle } from "react-native";
|
||||||
import { C } from "../lib/theme";
|
import { C, radius, shadow, space, type } from "../lib/theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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={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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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, ...props }: TextInputProps & { label: string }) {
|
export function Field({ label, ...props }: TextInputProps & { label: string }) {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={{ marginBottom: space.lg }}>
|
||||||
<Text style={u.label}>{label}</Text>
|
<Text style={u.label}>{label}</Text>
|
||||||
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
|
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
|
||||||
</View>
|
</View>
|
||||||
@@ -24,18 +78,21 @@ export function Chips<T extends string>({
|
|||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 14 }}>
|
<View style={{ marginBottom: space.lg }}>
|
||||||
<Text style={u.label}>{label}</Text>
|
<Text style={u.label}>{label}</Text>
|
||||||
<View style={u.chipRow}>
|
<View style={u.chipRow}>
|
||||||
{options.map((o) => (
|
{options.map((o) => {
|
||||||
<Pressable
|
const active = value === o;
|
||||||
key={o}
|
return (
|
||||||
style={[u.chip, value === o && u.chipActive]}
|
<Pressable
|
||||||
onPress={() => onChange(o)}
|
key={o}
|
||||||
>
|
style={({ pressed }) => [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]}
|
||||||
<Text style={[u.chipText, value === o && { color: C.text }]}>{labels?.[o] ?? o}</Text>
|
onPress={() => onChange(o)}
|
||||||
</Pressable>
|
>
|
||||||
))}
|
<Text style={[u.chipText, active && u.chipTextActive]}>{labels?.[o] ?? o}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -45,50 +102,108 @@ export function Button({
|
|||||||
title,
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
danger,
|
danger,
|
||||||
|
secondary,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
secondary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
|
||||||
|
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
|
style={({ pressed }) => [u.button, variant, pressed && { opacity: 0.75 }]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
|
<Text style={[u.buttonText, textVariant]}>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const u = StyleSheet.create({
|
const u = StyleSheet.create({
|
||||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
screen: { flex: 1, backgroundColor: C.bg },
|
||||||
|
|
||||||
|
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: {
|
input: {
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
color: C.text,
|
color: C.text,
|
||||||
borderRadius: 10,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
padding: 12,
|
paddingHorizontal: space.md,
|
||||||
|
paddingVertical: space.md,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
},
|
},
|
||||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
||||||
|
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||||
chip: {
|
chip: {
|
||||||
paddingVertical: 8,
|
paddingVertical: space.sm,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: space.lg - 2,
|
||||||
borderRadius: 20,
|
borderRadius: radius.pill,
|
||||||
backgroundColor: C.surface,
|
backgroundColor: C.surface,
|
||||||
borderWidth: 1,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
},
|
},
|
||||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||||
chipText: { color: C.muted, fontSize: 14 },
|
chipPressed: { backgroundColor: C.surface2 },
|
||||||
|
chipText: { ...type.chip, color: C.textSecondary },
|
||||||
|
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||||
|
|
||||||
button: {
|
button: {
|
||||||
backgroundColor: C.accent,
|
backgroundColor: C.accent,
|
||||||
borderRadius: 10,
|
borderRadius: radius.md,
|
||||||
padding: 14,
|
paddingVertical: space.lg - 2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: 8,
|
marginTop: space.sm,
|
||||||
marginBottom: 12,
|
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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
|
|||||||
4: "Low",
|
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}`> = {
|
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
|
||||||
1: "#E05B5B",
|
1: "#E5484D",
|
||||||
2: "#E0A93E",
|
2: "#D98324",
|
||||||
3: "#5B8DEF",
|
3: "#2F6FED",
|
||||||
4: "#6B7280",
|
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 {
|
export function formatEstimate(min: number | null): string | null {
|
||||||
|
|||||||
@@ -1,12 +1,115 @@
|
|||||||
export const C = {
|
// Design tokens.
|
||||||
bg: "#0F1115",
|
//
|
||||||
surface: "#1A1D24",
|
// The look is modelled on Things 3: a light, calm canvas, generous whitespace,
|
||||||
surface2: "#232733",
|
// hairline inset separators instead of boxes, and exactly one accent colour
|
||||||
border: "#2C313D",
|
// used sparingly. Everything visual in the app resolves through this file —
|
||||||
text: "#E6E8EC",
|
// no component hard-codes a hex value (priority flags are the one exception,
|
||||||
muted: "#8A909C",
|
// and they live in `priority.ts` alongside their labels).
|
||||||
accent: "#5B8DEF",
|
|
||||||
done: "#4CAF7D",
|
/** Named palette. `dark` is complete and ready to wire to a theme toggle. */
|
||||||
danger: "#E05B5B",
|
const light = {
|
||||||
warn: "#E0A93E",
|
/** 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;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user