Skip to content

Commit cf1f450

Browse files
os-zhuangclaude
andcommitted
feat(ux): dark mode with light/dark/system appearance picker
Wire a real theme switch end-to-end. setTheme now drives the NativeWind color scheme and persists to MMKV; the saved scheme is re-applied from the always-rendered root layout on cold start (the store's module side-effect alone never ran because the first screen doesn't import it). The tab bar, previously a hardcoded white bar, follows the scheme too. - stores/ui-store: setTheme → colorScheme.set + MMKV persist; load on init - ThemeSelector + /appearance screen, entry under More › Preferences - (tabs)/_layout: theme-aware tab bar colors - i18n: appearance.{title,light,dark,system} + more.appearance (en/zh/ar) - jest: mock nativewind colorScheme so ui-store tests load Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a577d56 commit cf1f450

10 files changed

Lines changed: 183 additions & 12 deletions

File tree

‎app/(tabs)/_layout.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Tabs } from "expo-router";
22
import { useTranslation } from "react-i18next";
3+
import { useColorScheme } from "nativewind";
34
import { useSafeAreaInsets } from "react-native-safe-area-context";
45
import {
56
Home,
@@ -11,6 +12,8 @@ import {
1112

1213
export default function TabLayout() {
1314
const { t } = useTranslation();
15+
const { colorScheme } = useColorScheme();
16+
const isDark = colorScheme === "dark";
1417
const insets = useSafeAreaInsets();
1518
// Without a bottom safe-area inset (web, or a device with no home indicator)
1619
// the labels sit flush against the bottom edge and get clipped. Reserve a
@@ -23,11 +26,13 @@ export default function TabLayout() {
2326
// double up with each screen's in-body title on native. Every tab
2427
// screen renders its own large title instead (iOS-style root header).
2528
headerShown: false,
26-
tabBarActiveTintColor: "#1e40af",
27-
tabBarInactiveTintColor: "#94a3b8",
29+
// Theme-aware: the tab bar's hardcoded white background stayed light in
30+
// dark mode, leaving a glaring white bar under a dark app.
31+
tabBarActiveTintColor: isDark ? "#60a5fa" : "#1e40af",
32+
tabBarInactiveTintColor: isDark ? "#64748b" : "#94a3b8",
2833
tabBarStyle: {
29-
borderTopColor: "#e2e8f0",
30-
backgroundColor: "#ffffff",
34+
borderTopColor: isDark ? "#1e293b" : "#e2e8f0",
35+
backgroundColor: isDark ? "#0b1120" : "#ffffff",
3136
height: 56 + bottomPad,
3237
paddingBottom: bottomPad,
3338
paddingTop: 6,

‎app/(tabs)/more.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Workflow,
1111
Inbox,
1212
Sparkles,
13+
Palette,
1314
} from "lucide-react-native";
1415
import { useRouter } from "expo-router";
1516
import { useTranslation } from "react-i18next";
@@ -144,6 +145,11 @@ export default function MoreScreen() {
144145

145146
{/* Preferences */}
146147
<SectionHeader title={t("more.sectionPreferences")} />
148+
<MenuItem
149+
icon={<Palette size={20} color="#64748b" />}
150+
label={t("more.appearance")}
151+
onPress={() => router.push("/appearance")}
152+
/>
147153
<MenuItem
148154
icon={<Globe size={20} color="#64748b" />}
149155
label={t("more.language")}

‎app/_layout.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { Stack, useRouter, useSegments } from "expo-router";
66
import { StatusBar } from "expo-status-bar";
77
import * as Linking from "expo-linking";
88
import { GestureHandlerRootView } from "react-native-gesture-handler";
9+
import { colorScheme } from "nativewind";
910
import { SafeAreaProvider } from "react-native-safe-area-context";
1011
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1112
import { ObjectStackProvider } from "@objectstack/client-react";
1213
import { authClient } from "~/lib/auth-client";
1314
import { createObjectStackClient } from "~/lib/objectstack";
1415
import { useServerStore } from "~/stores/server-store";
16+
import { useUIStore } from "~/stores/ui-store";
1517
import { usePushNotifications } from "~/hooks/usePushNotifications";
1618
import { ToastProvider } from "~/components/ui/Toast";
1719
import { ConfirmProvider } from "~/components/ui/ConfirmDialog";
@@ -71,12 +73,21 @@ export default function RootLayout() {
7173
const serverUrl = useServerStore((s) => s.serverUrl);
7274
const isReady = useServerStore((s) => s.isReady);
7375
const hydrate = useServerStore((s) => s.hydrate);
76+
const themeMode = useUIStore((s) => s.theme);
7477

7578
// On mount, load the persisted server URL and re-target the auth/data clients.
7679
useEffect(() => {
7780
void hydrate();
7881
}, [hydrate]);
7982

83+
// Apply the persisted color scheme from the root (always rendered) — the
84+
// store's own module-load side-effect only runs once something imports it,
85+
// which the first screen doesn't, so the saved theme wasn't applied on a cold
86+
// start.
87+
useEffect(() => {
88+
colorScheme.set(themeMode);
89+
}, [themeMode]);
90+
8091
useProtectedRoute(serverUrl, isReady);
8192

8293
const { data: session } = authClient.useSession();
@@ -130,6 +141,7 @@ export default function RootLayout() {
130141
<Stack.Screen name="(tabs)" />
131142
<Stack.Screen name="(app)" />
132143
<Stack.Screen name="account" />
144+
<Stack.Screen name="appearance" />
133145
<Stack.Screen name="ai" />
134146
</Stack>
135147
</ConfirmProvider>

‎app/appearance.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ScrollView } from "react-native";
2+
import { SafeAreaView } from "react-native-safe-area-context";
3+
import { useTranslation } from "react-i18next";
4+
import { ScreenHeader } from "~/components/common/ScreenHeader";
5+
import { ThemeSelector } from "~/components/common/ThemeSelector";
6+
7+
/**
8+
* Appearance — switch the app's color scheme (light / dark / system). Backed by
9+
* `useUIStore.setTheme` → NativeWind, applied live across every screen.
10+
*/
11+
export default function AppearanceScreen() {
12+
const { t } = useTranslation();
13+
return (
14+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
15+
<ScreenHeader title={t("appearance.title")} />
16+
<ScrollView className="flex-1" contentContainerClassName="px-5 pt-4">
17+
<ThemeSelector />
18+
</ScrollView>
19+
</SafeAreaView>
20+
);
21+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from "react";
2+
import { View, Text, Pressable } from "react-native";
3+
import { useTranslation } from "react-i18next";
4+
import { Check, Sun, Moon, SmartphoneNfc } from "lucide-react-native";
5+
import * as Haptics from "expo-haptics";
6+
import { useUIStore, type ThemeMode } from "~/stores/ui-store";
7+
import { cn } from "~/lib/utils";
8+
9+
const OPTIONS: { mode: ThemeMode; icon: typeof Sun; labelKey: string }[] = [
10+
{ mode: "light", icon: Sun, labelKey: "appearance.light" },
11+
{ mode: "dark", icon: Moon, labelKey: "appearance.dark" },
12+
{ mode: "system", icon: SmartphoneNfc, labelKey: "appearance.system" },
13+
];
14+
15+
/**
16+
* Theme/appearance selector — light / dark / system. Mirrors LanguageSelector;
17+
* writes through `useUIStore.setTheme`, which applies the NativeWind color
18+
* scheme live and persists the choice.
19+
*/
20+
export function ThemeSelector({ className }: { className?: string }) {
21+
const { t } = useTranslation();
22+
const theme = useUIStore((s) => s.theme);
23+
const setTheme = useUIStore((s) => s.setTheme);
24+
25+
return (
26+
<View className={cn("gap-1", className)}>
27+
<Text className="mb-2 text-sm font-medium text-muted-foreground">
28+
{t("appearance.title")}
29+
</Text>
30+
{OPTIONS.map(({ mode, icon: Icon, labelKey }) => {
31+
const isActive = theme === mode;
32+
return (
33+
<Pressable
34+
key={mode}
35+
accessibilityRole="button"
36+
accessibilityState={{ selected: isActive }}
37+
accessibilityLabel={t(labelKey)}
38+
className={cn(
39+
"flex-row items-center justify-between rounded-lg px-4 py-3 active:opacity-70",
40+
isActive ? "bg-primary/10" : "bg-card",
41+
)}
42+
onPress={() => {
43+
if (!isActive) {
44+
void Haptics.selectionAsync();
45+
setTheme(mode);
46+
}
47+
}}
48+
>
49+
<View className="flex-row items-center gap-3">
50+
<Icon size={20} color={isActive ? "#1e40af" : "#64748b"} />
51+
<Text
52+
className={cn(
53+
"text-base",
54+
isActive ? "font-semibold text-primary" : "text-foreground",
55+
)}
56+
>
57+
{t(labelKey)}
58+
</Text>
59+
</View>
60+
{isActive && <Check size={18} color="#1e40af" />}
61+
</Pressable>
62+
);
63+
})}
64+
</View>
65+
);
66+
}

‎jest.setup.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ jest.mock("react-native-css-interop", () => ({
99
remapProps: jest.fn(),
1010
}));
1111

12+
/* ---- nativewind (color-scheme API used by ui-store / layouts) ---- */
13+
jest.mock("nativewind", () => {
14+
let scheme: "light" | "dark" | "system" = "system";
15+
return {
16+
colorScheme: {
17+
set: jest.fn((v: "light" | "dark" | "system") => {
18+
scheme = v;
19+
}),
20+
get: jest.fn(() => scheme),
21+
},
22+
useColorScheme: () => ({
23+
colorScheme: scheme === "system" ? "light" : scheme,
24+
setColorScheme: jest.fn(),
25+
}),
26+
};
27+
});
28+
1229
/* ---- expo/fetch (streaming fetch — native module, unloadable in Node) ---- */
1330
jest.mock("expo/fetch", () => ({ fetch: jest.fn() }));
1431

‎locales/ar.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@
172172
"approvals": "الموافقات",
173173
"flows": "التدفقات",
174174
"language": "اللغة",
175-
"signOut": "تسجيل الخروج"
175+
"signOut": "تسجيل الخروج",
176+
"appearance": "المظهر"
176177
},
177178
"ai": {
178179
"title": "المساعد الذكي",
@@ -251,5 +252,11 @@
251252
"dashboard": "لوحة التحكم",
252253
"noWidgets": "لم يتم تهيئة أي أدوات.",
253254
"refreshing": "جارٍ التحديث…"
255+
},
256+
"appearance": {
257+
"title": "المظهر",
258+
"light": "فاتح",
259+
"dark": "داكن",
260+
"system": "اتباع النظام"
254261
}
255262
}

‎locales/en.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@
169169
"approvals": "Approvals",
170170
"flows": "Flows",
171171
"language": "Language",
172-
"signOut": "Sign Out"
172+
"signOut": "Sign Out",
173+
"appearance": "Appearance"
173174
},
174175
"ai": {
175176
"title": "AI Assistant",
@@ -240,5 +241,11 @@
240241
"dashboard": "Dashboard",
241242
"noWidgets": "No widgets configured.",
242243
"refreshing": "Refreshing…"
244+
},
245+
"appearance": {
246+
"title": "Appearance",
247+
"light": "Light",
248+
"dark": "Dark",
249+
"system": "System"
243250
}
244251
}

‎locales/zh.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@
167167
"approvals": "审批",
168168
"flows": "流程",
169169
"language": "语言",
170-
"signOut": "退出登录"
170+
"signOut": "退出登录",
171+
"appearance": "外观"
171172
},
172173
"ai": {
173174
"title": "AI 助手",
@@ -236,5 +237,11 @@
236237
"dashboard": "仪表盘",
237238
"noWidgets": "未配置小部件。",
238239
"refreshing": "刷新中…"
240+
},
241+
"appearance": {
242+
"title": "外观",
243+
"light": "浅色",
244+
"dark": "深色",
245+
"system": "跟随系统"
239246
}
240247
}

‎stores/ui-store.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
11
import { create } from "zustand";
2+
import { colorScheme } from "nativewind";
3+
import { createMMKV } from "react-native-mmkv";
24
import i18n from "~/lib/i18n";
35
import type { SupportedLanguage } from "~/lib/i18n";
46

7+
export type ThemeMode = "light" | "dark" | "system";
8+
9+
const storage = createMMKV({ id: "objectstack-ui" });
10+
const THEME_KEY = "theme";
11+
12+
function loadTheme(): ThemeMode {
13+
const v = storage.getString(THEME_KEY);
14+
return v === "light" || v === "dark" || v === "system" ? v : "system";
15+
}
16+
517
interface UIState {
618
/** Current theme mode */
7-
theme: "light" | "dark" | "system";
8-
/** Set theme */
9-
setTheme: (theme: "light" | "dark" | "system") => void;
19+
theme: ThemeMode;
20+
/** Set theme — applies the NativeWind color scheme and persists it */
21+
setTheme: (theme: ThemeMode) => void;
1022
/** Current language code */
1123
language: SupportedLanguage;
1224
/** Change language (updates i18next and store) */
1325
setLanguage: (lang: SupportedLanguage) => void;
1426
}
1527

28+
const initialTheme = loadTheme();
29+
30+
// Apply the persisted theme up front so the first paint matches the user's
31+
// choice. `setTheme` previously only set state and never touched NativeWind, so
32+
// the theme was inert and dark mode was unreachable.
33+
colorScheme.set(initialTheme);
34+
1635
export const useUIStore = create<UIState>((set) => ({
17-
theme: "system",
18-
setTheme: (theme) => set({ theme }),
36+
theme: initialTheme,
37+
setTheme: (theme) => {
38+
colorScheme.set(theme);
39+
storage.set(THEME_KEY, theme);
40+
set({ theme });
41+
},
1942
language: (i18n.language ?? "en") as SupportedLanguage,
2043
setLanguage: (lang) => {
2144
i18n.changeLanguage(lang);

0 commit comments

Comments
 (0)