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 { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { AppState, Pressable } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { getState, hydrate, subscribe, sync } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import Icon from "../components/Icon";
|
||||
import { C, space } from "../lib/theme";
|
||||
import { refreshNextUpWidget } from "../widget/refresh";
|
||||
|
||||
export default function RootLayout() {
|
||||
@@ -35,12 +36,16 @@ export default function RootLayout() {
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style="dark" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: C.bg },
|
||||
headerTintColor: C.text,
|
||||
// No hairline under the bar: the header should melt into the grouped
|
||||
// background so the in-content Title carries the hierarchy instead.
|
||||
headerShadowVisible: false,
|
||||
headerTitleStyle: { fontSize: 17, fontWeight: "600", color: C.text, letterSpacing: -0.2 },
|
||||
headerBackButtonDisplayMode: "minimal",
|
||||
contentStyle: { backgroundColor: C.bg },
|
||||
}}
|
||||
>
|
||||
@@ -49,8 +54,13 @@ export default function RootLayout() {
|
||||
options={{
|
||||
title: "GTD",
|
||||
headerRight: () => (
|
||||
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
|
||||
⚙️
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable
|
||||
hitSlop={12}
|
||||
style={({ pressed }) => [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
|
||||
>
|
||||
<Icon name="settings" size={22} color={C.textSecondary} />
|
||||
</Pressable>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import TaskRow from "../components/TaskRow";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
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 { daysSince, useLastReview } from "../lib/review";
|
||||
import { todayTasks, visibleTasksByStatus } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import { C, shadow, space, type } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
|
||||
const MENU = [
|
||||
{ icon: "📅", label: "Today", href: "/today" },
|
||||
{ icon: "▶️", label: "Next", href: "/list/next" },
|
||||
{ icon: "⏳", label: "Waiting", href: "/list/waiting" },
|
||||
{ icon: "💭", label: "Someday", href: "/list/someday" },
|
||||
{ icon: "📁", label: "Projects", href: "/projects" },
|
||||
{ icon: "🔄", label: "Review", href: "/review" },
|
||||
] as const;
|
||||
const MENU: { icon: IconName; label: string; href: string }[] = [
|
||||
{ icon: "today", label: "Today", href: "/today" },
|
||||
{ icon: "next", label: "Next", href: "/list/next" },
|
||||
{ icon: "waiting", label: "Waiting", href: "/list/waiting" },
|
||||
{ icon: "someday", label: "Someday", href: "/list/someday" },
|
||||
{ icon: "projects", label: "Projects", href: "/projects" },
|
||||
{ icon: "review", label: "Review", href: "/review" },
|
||||
];
|
||||
|
||||
const PREVIEW = 3;
|
||||
|
||||
/**
|
||||
* One tappable line of status — overdue, review due, sync trouble. Rendered as
|
||||
* a real row with a chevron rather than coloured micro-text, so it reads as
|
||||
* something you can act on.
|
||||
*/
|
||||
function Alert({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
|
||||
const body = (
|
||||
<View style={s.alert}>
|
||||
<View style={[s.dot, { backgroundColor: tone }]} />
|
||||
<Text style={[s.alertText, { color: tone }]}>{text}</Text>
|
||||
{!!onPress && <Icon name="chevron" size={16} color={tone} />}
|
||||
</View>
|
||||
);
|
||||
if (!onPress) return body;
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => pressed && { opacity: 0.6 }}>
|
||||
{body}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -25,103 +50,146 @@ export default function Home() {
|
||||
useFocusEffect(reloadReview);
|
||||
const reviewDays = daysSince(lastReview);
|
||||
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
|
||||
const preview = next.slice(0, PREVIEW);
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.sectionTitle}>Next up</Text>
|
||||
{next.slice(0, 3).map((t) => (
|
||||
<TaskRow key={t.id} task={t} />
|
||||
))}
|
||||
{next.length === 0 && <Text style={s.empty}>Nothing next. Tap + to add.</Text>}
|
||||
{next.length > 3 && (
|
||||
<Pressable onPress={() => router.push("/list/next")}>
|
||||
<Text style={s.viewAll}>all next actions ({next.length}) →</Text>
|
||||
<Screen>
|
||||
<ScrollView contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
|
||||
<Title count={next.length}>Next up</Title>
|
||||
|
||||
{preview.length === 0 ? (
|
||||
<Empty>Nothing queued. Add the next thing on your mind.</Empty>
|
||||
) : (
|
||||
<Card>
|
||||
{preview.map((t, i) => (
|
||||
<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>
|
||||
)}
|
||||
{overdue.length > 0 && (
|
||||
<Pressable onPress={() => router.push("/today")}>
|
||||
<Text style={s.overdue}>{overdue.length} overdue →</Text>
|
||||
</Pressable>
|
||||
|
||||
{(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
|
||||
<>
|
||||
<SectionHeader>Needs attention</SectionHeader>
|
||||
<Card>
|
||||
{overdue.length > 0 && (
|
||||
<Alert
|
||||
tone={C.danger}
|
||||
text={`${overdue.length} overdue`}
|
||||
onPress={() => router.push("/today")}
|
||||
/>
|
||||
)}
|
||||
{reviewDue && (
|
||||
<Alert
|
||||
tone={C.warn}
|
||||
text={
|
||||
lastReview === null
|
||||
? "No weekly review yet"
|
||||
: `Weekly review is ${reviewDays} days overdue`
|
||||
}
|
||||
onPress={() => router.push("/review")}
|
||||
/>
|
||||
)}
|
||||
{store.offline && (
|
||||
<Alert
|
||||
tone={C.textSecondary}
|
||||
text={
|
||||
store.pending > 0
|
||||
? `Offline — ${store.pending} change${store.pending === 1 ? "" : "s"} queued`
|
||||
: "Offline"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!store.syncError && <Alert tone={C.danger} text={store.syncError} />}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{reviewDue && (
|
||||
<Pressable onPress={() => router.push("/review")}>
|
||||
<Text style={s.reviewNudge}>
|
||||
{lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{store.offline && (
|
||||
<Text style={s.offline}>
|
||||
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{store.syncError && <Text style={s.error}>{store.syncError}</Text>}
|
||||
</View>
|
||||
<View style={s.menu}>
|
||||
</ScrollView>
|
||||
|
||||
<View style={s.tabBar}>
|
||||
{MENU.map((m) => (
|
||||
<Pressable key={m.label} style={s.menuItem} onPress={() => router.push(m.href)}>
|
||||
<Text style={s.menuIcon}>{m.icon}</Text>
|
||||
<Text style={s.menuLabel}>{m.label}</Text>
|
||||
<Pressable
|
||||
key={m.label}
|
||||
style={({ pressed }) => [s.tab, pressed && { opacity: 0.5 }]}
|
||||
onPress={() => {
|
||||
haptic.tap();
|
||||
router.push(m.href);
|
||||
}}
|
||||
>
|
||||
<Icon name={m.icon} size={21} color={C.textSecondary} bg={C.surface} />
|
||||
<Text style={s.tabLabel}>{m.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={s.fab}
|
||||
style={({ pressed }) => [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
|
||||
onPress={() => {
|
||||
haptic.tap();
|
||||
router.push("/add");
|
||||
}}
|
||||
>
|
||||
<Text style={s.fabText}>+</Text>
|
||||
<Icon name="plus" size={26} color={C.onAccent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
sectionTitle: {
|
||||
color: C.muted,
|
||||
fontSize: 13,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 18,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
||||
viewAll: { color: C.muted, fontSize: 14, padding: 16 },
|
||||
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
|
||||
reviewNudge: { color: C.warn, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
|
||||
error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
|
||||
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
|
||||
menu: {
|
||||
content: { paddingHorizontal: space.lg, paddingBottom: 120 },
|
||||
|
||||
viewAll: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: space.xs,
|
||||
paddingVertical: space.md,
|
||||
paddingHorizontal: space.xs,
|
||||
},
|
||||
viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
|
||||
|
||||
alert: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: space.md,
|
||||
paddingVertical: space.md + 1,
|
||||
paddingHorizontal: space.lg,
|
||||
},
|
||||
dot: { width: 7, height: 7, borderRadius: 999 },
|
||||
alertText: { ...type.body, flex: 1, fontWeight: "500" },
|
||||
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: C.surface,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: C.border,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 18,
|
||||
backgroundColor: C.bg,
|
||||
paddingTop: space.md,
|
||||
paddingBottom: space.xxl,
|
||||
},
|
||||
menuItem: { flex: 1, alignItems: "center", gap: 3 },
|
||||
menuIcon: { fontSize: 20 },
|
||||
menuLabel: { color: C.muted, fontSize: 11 },
|
||||
tab: { flex: 1, alignItems: "center", gap: 5 },
|
||||
tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
|
||||
|
||||
fab: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 92,
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 29,
|
||||
right: space.xl,
|
||||
bottom: 104,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: C.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
elevation: 6,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
...shadow.fab,
|
||||
},
|
||||
fabText: { color: "#FFFFFF", fontSize: 28, lineHeight: 32, fontWeight: "600" },
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
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 WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||
@@ -97,8 +98,8 @@ function Calendar({ value, onPick }: { value: string | null; onPick: (v: string)
|
||||
<Text
|
||||
style={[
|
||||
s.dayText,
|
||||
!inMonth && { color: C.border },
|
||||
selected && { color: "#FFFFFF", fontWeight: "700" },
|
||||
!inMonth && { color: C.muted, opacity: 0.45 },
|
||||
selected && { color: C.onAccent, fontWeight: "700" },
|
||||
!selected && isToday && { color: C.accent },
|
||||
]}
|
||||
>
|
||||
@@ -133,9 +134,9 @@ export default function DateField({
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<View style={{ marginBottom: space.lg }}>
|
||||
<View style={s.labelRow}>
|
||||
<Text style={s.label}>{label}</Text>
|
||||
<SectionHeader>{label}</SectionHeader>
|
||||
{value != null && <Text style={s.chosen}>{pretty(value)}</Text>}
|
||||
</View>
|
||||
<View style={s.rowWrap}>
|
||||
@@ -148,7 +149,7 @@ export default function DateField({
|
||||
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
|
||||
@@ -158,7 +159,7 @@ export default function DateField({
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<Text style={[s.chipText, open && { color: C.text }]}>📆 Pick…</Text>
|
||||
<Text style={[s.chipText, open && s.chipTextActive]}>Pick a date…</Text>
|
||||
</Pressable>
|
||||
{clearable && value != null && (
|
||||
<Pressable
|
||||
@@ -186,34 +187,34 @@ export default function DateField({
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 },
|
||||
label: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
chosen: { color: C.accent, fontSize: 14, fontWeight: "600" },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
||||
chipText: { color: C.muted, fontSize: 14 },
|
||||
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||
chipText: { ...type.chip, color: C.textSecondary },
|
||||
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||
calendar: {
|
||||
marginTop: 10,
|
||||
marginTop: space.md,
|
||||
backgroundColor: C.surface,
|
||||
borderRadius: 14,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
padding: 10,
|
||||
padding: space.md,
|
||||
},
|
||||
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 6 },
|
||||
calTitle: { color: C.text, fontSize: 15, fontWeight: "600" },
|
||||
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: space.sm },
|
||||
calTitle: { color: C.text, fontSize: 16, fontWeight: "600", letterSpacing: -0.2 },
|
||||
arrow: { paddingHorizontal: 14, paddingVertical: 4 },
|
||||
arrowText: { color: C.accent, fontSize: 22, fontWeight: "600" },
|
||||
arrowText: { color: C.accent, fontSize: 24, fontWeight: "500" },
|
||||
week: { flexDirection: "row" },
|
||||
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, paddingVertical: 4 },
|
||||
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, fontWeight: "600", letterSpacing: 0.4, paddingVertical: 6 },
|
||||
day: {
|
||||
flex: 1,
|
||||
aspectRatio: 1.15,
|
||||
@@ -223,6 +224,6 @@ const s = StyleSheet.create({
|
||||
margin: 1,
|
||||
},
|
||||
daySelected: { backgroundColor: C.accent },
|
||||
dayToday: { borderWidth: 1, borderColor: C.accent },
|
||||
dayText: { color: C.text, fontSize: 14 },
|
||||
dayToday: { backgroundColor: C.accentSoft },
|
||||
dayText: { color: C.text, fontSize: 15 },
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { haptic } from "../lib/haptics";
|
||||
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];
|
||||
|
||||
@@ -17,21 +18,25 @@ export default function EstimateChips({
|
||||
}) {
|
||||
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<Text style={s.label}>Estimated time</Text>
|
||||
<View style={s.wrap}>
|
||||
<SectionHeader>Estimated time</SectionHeader>
|
||||
<View style={s.rowWrap}>
|
||||
{options.map((m) => {
|
||||
const active = value === m;
|
||||
return (
|
||||
<Pressable
|
||||
key={m}
|
||||
style={[s.chip, active && s.chipActive]}
|
||||
style={({ pressed }) => [
|
||||
s.chip,
|
||||
active && s.chipActive,
|
||||
pressed && !active && { backgroundColor: C.surface2 },
|
||||
]}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -41,16 +46,17 @@ export default function EstimateChips({
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
wrap: { marginBottom: space.lg },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 15,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
||||
chipText: { color: C.muted, fontSize: 14 },
|
||||
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||
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 { haptic } from "../lib/haptics";
|
||||
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
|
||||
import { C } from "../lib/theme";
|
||||
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
|
||||
import { C, radius, space, type } from "../lib/theme";
|
||||
import type { Task } from "../lib/types";
|
||||
import { SectionHeader } from "./ui";
|
||||
|
||||
export default function PriorityChips({
|
||||
value,
|
||||
@@ -12,8 +13,8 @@ export default function PriorityChips({
|
||||
onChange: (p: Task["priority"]) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<Text style={s.label}>Priority</Text>
|
||||
<View style={s.wrap}>
|
||||
<SectionHeader>Priority</SectionHeader>
|
||||
<View style={s.rowWrap}>
|
||||
{PRIORITIES.map((p) => {
|
||||
const active = value === p;
|
||||
@@ -21,14 +22,20 @@ export default function PriorityChips({
|
||||
return (
|
||||
<Pressable
|
||||
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={() => {
|
||||
haptic.select();
|
||||
onChange(p);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -38,19 +45,19 @@ export default function PriorityChips({
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
wrap: { marginBottom: space.lg },
|
||||
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||
chip: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 13,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: C.border,
|
||||
},
|
||||
flag: { width: 10, height: 10, borderRadius: 5 },
|
||||
text: { color: C.muted, fontSize: 14 },
|
||||
flag: { width: 9, height: 9, borderRadius: 999 },
|
||||
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 { haptic } from "../lib/haptics";
|
||||
import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import { C, radius, shadow, space, type } from "../lib/theme";
|
||||
import { useStore } from "../lib/useStore";
|
||||
import type { TaskStatus } from "../lib/types";
|
||||
import TaskRow from "./TaskRow";
|
||||
import TaskRow, { TaskSeparator } from "./TaskRow";
|
||||
|
||||
// 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
|
||||
@@ -50,13 +50,31 @@ export default function TaskListScreen({
|
||||
reorderTask(data[to].id, status, to);
|
||||
}
|
||||
}}
|
||||
renderItem={({ item, drag, isActive }) => (
|
||||
<ScaleDecorator activeScale={1.03}>
|
||||
<View style={isActive && s.activeRow}>
|
||||
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} />
|
||||
</View>
|
||||
</ScaleDecorator>
|
||||
)}
|
||||
// The card is drawn by the rows themselves so drag-and-drop still owns
|
||||
// the whole list: first/last rows round their outer corners, and a
|
||||
// lifted row goes full-radius so it reads as detached from the stack.
|
||||
renderItem={({ item, drag, isActive, getIndex }) => {
|
||||
const i = getIndex() ?? 0;
|
||||
return (
|
||||
<ScaleDecorator activeScale={1.02}>
|
||||
<View
|
||||
style={[
|
||||
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
|
||||
refreshing={refreshing}
|
||||
@@ -71,14 +89,17 @@ export default function TaskListScreen({
|
||||
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
|
||||
ListFooterComponent={
|
||||
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
|
||||
<Pressable onPress={() => setShowDeferred((v) => !v)}>
|
||||
<Text style={s.deferredFooter}>
|
||||
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
|
||||
<Pressable
|
||||
onPress={() => setShowDeferred((v) => !v)}
|
||||
style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<Text style={s.deferredText}>
|
||||
{showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
contentContainerStyle={s.content}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -86,7 +107,23 @@ export default function TaskListScreen({
|
||||
|
||||
const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
||||
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
|
||||
content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 },
|
||||
row: { backgroundColor: C.surface },
|
||||
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 { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
|
||||
import { updateTask } from "../lib/store";
|
||||
import { C } from "../lib/theme";
|
||||
import { C, radius, space, type } from "../lib/theme";
|
||||
import type { Task, TaskStatus } from "../lib/types";
|
||||
|
||||
export default function TaskRow({
|
||||
@@ -22,6 +22,7 @@ export default function TaskRow({
|
||||
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;
|
||||
@@ -37,33 +38,42 @@ export default function TaskRow({
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const meta = base || due;
|
||||
const flag = PRIORITY_COLOR[task.priority];
|
||||
|
||||
const toggle = () => {
|
||||
const done = task.status !== "done";
|
||||
if (done) haptic.success();
|
||||
const next = task.status !== "done";
|
||||
if (next) haptic.success();
|
||||
else haptic.tap();
|
||||
updateTask(task.id, { status: done ? "done" : uncheckTo });
|
||||
updateTask(task.id, { status: next ? "done" : uncheckTo });
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(150)}>
|
||||
<Pressable style={s.row} onPress={() => router.push(`/task/${task.id}`)} onLongPress={onLongPress} delayLongPress={200}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [s.row, pressed && s.rowPressed]}
|
||||
onPress={() => router.push(`/task/${task.id}`)}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={200}
|
||||
>
|
||||
{/* Rounded square rather than a circle — the Things checkbox shape.
|
||||
The border carries the priority colour, so priority is legible at a
|
||||
glance without spending a whole column on a flag. */}
|
||||
<Pressable
|
||||
style={[s.checkbox, { borderColor: PRIORITY_COLOR[task.priority] }]}
|
||||
style={[s.checkbox, { borderColor: done ? C.done : flag }, done && s.checkboxDone]}
|
||||
hitSlop={10}
|
||||
onPress={toggle}
|
||||
>
|
||||
{task.status === "done" && <View style={s.checkboxFill} />}
|
||||
{done && <Text style={s.check}>✓</Text>}
|
||||
</Pressable>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.title} numberOfLines={2}>
|
||||
<Text style={[s.title, done && s.titleDone]} numberOfLines={2}>
|
||||
{task.title}
|
||||
</Text>
|
||||
{!!meta && (
|
||||
<Text style={s.meta}>
|
||||
<Text style={s.meta} numberOfLines={1}>
|
||||
{base}
|
||||
{!!base && !!due && " · "}
|
||||
{due && <Text style={{ color: dueColor }}>{due.label}</Text>}
|
||||
{due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -76,21 +86,40 @@ const s = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: C.border,
|
||||
gap: space.md,
|
||||
paddingVertical: space.md + 2,
|
||||
paddingHorizontal: space.lg,
|
||||
backgroundColor: C.surface,
|
||||
},
|
||||
rowPressed: { backgroundColor: C.surface2 },
|
||||
checkbox: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
borderWidth: 2,
|
||||
borderRadius: 7,
|
||||
borderWidth: 1.5,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done },
|
||||
title: { color: C.text, fontSize: 16 },
|
||||
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
|
||||
checkboxDone: { backgroundColor: C.done },
|
||||
check: { color: "#FFFFFF", 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,63 @@
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
|
||||
import { C } from "../lib/theme";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type ViewStyle } from "react-native";
|
||||
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 }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<View style={{ marginBottom: space.lg }}>
|
||||
<Text style={u.label}>{label}</Text>
|
||||
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
|
||||
</View>
|
||||
@@ -24,18 +78,21 @@ export function Chips<T extends string>({
|
||||
labels?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<View style={{ marginBottom: space.lg }}>
|
||||
<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>
|
||||
))}
|
||||
{options.map((o) => {
|
||||
const active = value === o;
|
||||
return (
|
||||
<Pressable
|
||||
key={o}
|
||||
style={({ pressed }) => [u.chip, active && u.chipActive, pressed && !active && u.chipPressed]}
|
||||
onPress={() => onChange(o)}
|
||||
>
|
||||
<Text style={[u.chipText, active && u.chipTextActive]}>{labels?.[o] ?? o}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -45,50 +102,108 @@ export function Button({
|
||||
title,
|
||||
onPress,
|
||||
danger,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
danger?: boolean;
|
||||
secondary?: boolean;
|
||||
}) {
|
||||
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
|
||||
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
|
||||
return (
|
||||
<Pressable
|
||||
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
|
||||
style={({ pressed }) => [u.button, variant, pressed && { opacity: 0.75 }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
|
||||
<Text style={[u.buttonText, textVariant]}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const u = StyleSheet.create({
|
||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: space.md,
|
||||
paddingHorizontal: space.lg,
|
||||
paddingTop: space.md,
|
||||
paddingBottom: space.lg,
|
||||
},
|
||||
title: { ...type.title, color: C.text },
|
||||
titleCount: { ...type.title, color: C.muted, fontWeight: "600" },
|
||||
|
||||
sectionHeader: {
|
||||
...type.section,
|
||||
color: C.textSecondary,
|
||||
paddingHorizontal: space.xs,
|
||||
paddingTop: space.xl,
|
||||
paddingBottom: space.sm,
|
||||
},
|
||||
|
||||
card: {
|
||||
backgroundColor: C.surface,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: C.border,
|
||||
overflow: "hidden",
|
||||
...shadow.card,
|
||||
},
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: C.separator },
|
||||
|
||||
pill: {
|
||||
paddingHorizontal: space.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface2,
|
||||
},
|
||||
pillText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
|
||||
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 56, ...type.body },
|
||||
|
||||
label: {
|
||||
...type.section,
|
||||
color: C.textSecondary,
|
||||
marginBottom: space.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: C.surface,
|
||||
color: C.text,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: C.border,
|
||||
padding: 12,
|
||||
paddingHorizontal: space.md,
|
||||
paddingVertical: space.md,
|
||||
fontSize: 16,
|
||||
},
|
||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||
|
||||
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 20,
|
||||
paddingVertical: space.sm,
|
||||
paddingHorizontal: space.lg - 2,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: C.surface,
|
||||
borderWidth: 1,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: C.border,
|
||||
},
|
||||
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
|
||||
chipText: { color: C.muted, fontSize: 14 },
|
||||
chipActive: { backgroundColor: C.accentSoft, borderColor: C.accent },
|
||||
chipPressed: { backgroundColor: C.surface2 },
|
||||
chipText: { ...type.chip, color: C.textSecondary },
|
||||
chipTextActive: { color: C.accent, fontWeight: "600" },
|
||||
|
||||
button: {
|
||||
backgroundColor: C.accent,
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderRadius: radius.md,
|
||||
paddingVertical: space.lg - 2,
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
marginBottom: 12,
|
||||
marginTop: space.sm,
|
||||
marginBottom: space.md,
|
||||
},
|
||||
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
|
||||
buttonDanger: { backgroundColor: "transparent", borderWidth: StyleSheet.hairlineWidth, borderColor: C.danger },
|
||||
buttonSecondary: { backgroundColor: C.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: C.border },
|
||||
buttonText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
|
||||
buttonTextDanger: { color: C.danger },
|
||||
buttonTextSecondary: { color: C.text },
|
||||
});
|
||||
|
||||
@@ -9,11 +9,21 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
|
||||
4: "Low",
|
||||
};
|
||||
|
||||
// Todoist's flag mapping (urgent red → low grey), retuned to sit calmly on a
|
||||
// light canvas. `PRIORITY_TINT` is the same hue at wash strength, for the
|
||||
// selected state of a chip — a saturated fill would shout on a white card.
|
||||
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
|
||||
1: "#E05B5B",
|
||||
2: "#E0A93E",
|
||||
3: "#5B8DEF",
|
||||
4: "#6B7280",
|
||||
1: "#E5484D",
|
||||
2: "#D98324",
|
||||
3: "#2F6FED",
|
||||
4: "#9A9AA2",
|
||||
};
|
||||
|
||||
export const PRIORITY_TINT: Record<Task["priority"], `#${string}`> = {
|
||||
1: "#FDECEC",
|
||||
2: "#FBF0E2",
|
||||
3: "#E8F0FE",
|
||||
4: "#F0F0F3",
|
||||
};
|
||||
|
||||
export function formatEstimate(min: number | null): string | null {
|
||||
|
||||
@@ -1,12 +1,115 @@
|
||||
export const C = {
|
||||
bg: "#0F1115",
|
||||
surface: "#1A1D24",
|
||||
surface2: "#232733",
|
||||
border: "#2C313D",
|
||||
text: "#E6E8EC",
|
||||
muted: "#8A909C",
|
||||
accent: "#5B8DEF",
|
||||
done: "#4CAF7D",
|
||||
danger: "#E05B5B",
|
||||
warn: "#E0A93E",
|
||||
// Design tokens.
|
||||
//
|
||||
// The look is modelled on Things 3: a light, calm canvas, generous whitespace,
|
||||
// hairline inset separators instead of boxes, and exactly one accent colour
|
||||
// used sparingly. Everything visual in the app resolves through this file —
|
||||
// no component hard-codes a hex value (priority flags are the one exception,
|
||||
// and they live in `priority.ts` alongside their labels).
|
||||
|
||||
/** Named palette. `dark` is complete and ready to wire to a theme toggle. */
|
||||
const light = {
|
||||
/** Screen background behind grouped content. */
|
||||
bg: "#F7F7F9",
|
||||
/** Card / row surface that sits on `bg`. */
|
||||
surface: "#FFFFFF",
|
||||
/** Inputs, pressed states, subtle fills. */
|
||||
surface2: "#EFEFF3",
|
||||
/** Card outline. Deliberately barely-there. */
|
||||
border: "#E6E6EB",
|
||||
/** Inset hairline between rows inside a card. */
|
||||
separator: "#ECECF0",
|
||||
/** Primary reading colour. Near-black, never pure #000. */
|
||||
text: "#16161A",
|
||||
/** Supporting copy that should still be comfortably readable. */
|
||||
textSecondary: "#6E6E76",
|
||||
/** Metadata, placeholders, de-emphasised chrome. */
|
||||
muted: "#9A9AA2",
|
||||
accent: "#2F6FED",
|
||||
/** Tinted accent wash for selected chips and active states. */
|
||||
accentSoft: "#E8F0FE",
|
||||
done: "#2FA36B",
|
||||
danger: "#E5484D",
|
||||
warn: "#D98324",
|
||||
/** Text that sits on top of `accent`. */
|
||||
onAccent: "#FFFFFF",
|
||||
} as const;
|
||||
|
||||
const dark = {
|
||||
bg: "#0C0C0F",
|
||||
surface: "#17171B",
|
||||
surface2: "#212127",
|
||||
border: "#2A2A31",
|
||||
separator: "#26262C",
|
||||
text: "#F2F2F5",
|
||||
textSecondary: "#A6A6B0",
|
||||
muted: "#7A7A85",
|
||||
accent: "#4C86F0",
|
||||
accentSoft: "#1B2740",
|
||||
done: "#3FBF84",
|
||||
danger: "#F0605F",
|
||||
warn: "#E0A93E",
|
||||
onAccent: "#FFFFFF",
|
||||
} as const;
|
||||
|
||||
export const PALETTES = { light, dark } as const;
|
||||
|
||||
/**
|
||||
* Active palette. Styles are created at module scope (StyleSheet.create) so
|
||||
* this is a static choice rather than a hook — switching themes at runtime
|
||||
* would mean moving every stylesheet inside its component.
|
||||
*/
|
||||
export const C = light;
|
||||
|
||||
/** 4pt spacing grid. Screen gutter is `space.lg`. */
|
||||
export const space = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
xxl: 28,
|
||||
} as const;
|
||||
|
||||
export const radius = {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
/** Cards and grouped lists. */
|
||||
lg: 16,
|
||||
/** Chips and pills. */
|
||||
pill: 999,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type scale. `title` is the large in-content screen heading that replaces a
|
||||
* cramped nav-bar title; `row` is the task title size — 17pt, matching the
|
||||
* platform body size Things and Todoist both settle on.
|
||||
*/
|
||||
export const type = {
|
||||
title: { fontSize: 30, fontWeight: "700", letterSpacing: -0.6 },
|
||||
section: { fontSize: 13, fontWeight: "600", letterSpacing: 0.3 },
|
||||
row: { fontSize: 17, letterSpacing: -0.2 },
|
||||
body: { fontSize: 15 },
|
||||
meta: { fontSize: 13 },
|
||||
chip: { fontSize: 14, fontWeight: "500" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Card elevation. Kept extremely soft — a Things-like surface reads as paper,
|
||||
* not as a floating panel.
|
||||
*/
|
||||
export const shadow = {
|
||||
card: {
|
||||
shadowColor: "#0B0B12",
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 1,
|
||||
},
|
||||
fab: {
|
||||
shadowColor: "#0B0B12",
|
||||
shadowOpacity: 0.22,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user