diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 8e8403a..7cea0e5 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,5 +1,6 @@
import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next";
+import { useColorScheme } from "nativewind";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
Home,
@@ -11,6 +12,8 @@ import {
export default function TabLayout() {
const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === "dark";
const insets = useSafeAreaInsets();
// Without a bottom safe-area inset (web, or a device with no home indicator)
// the labels sit flush against the bottom edge and get clipped. Reserve a
@@ -23,11 +26,13 @@ export default function TabLayout() {
// double up with each screen's in-body title on native. Every tab
// screen renders its own large title instead (iOS-style root header).
headerShown: false,
- tabBarActiveTintColor: "#1e40af",
- tabBarInactiveTintColor: "#94a3b8",
+ // Theme-aware: the tab bar's hardcoded white background stayed light in
+ // dark mode, leaving a glaring white bar under a dark app.
+ tabBarActiveTintColor: isDark ? "#60a5fa" : "#1e40af",
+ tabBarInactiveTintColor: isDark ? "#64748b" : "#94a3b8",
tabBarStyle: {
- borderTopColor: "#e2e8f0",
- backgroundColor: "#ffffff",
+ borderTopColor: isDark ? "#1e293b" : "#e2e8f0",
+ backgroundColor: isDark ? "#0b1120" : "#ffffff",
height: 56 + bottomPad,
paddingBottom: bottomPad,
paddingTop: 6,
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index 864d381..4ee2182 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -10,6 +10,7 @@ import {
Workflow,
Inbox,
Sparkles,
+ Palette,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
@@ -144,6 +145,11 @@ export default function MoreScreen() {
{/* Preferences */}
+ }
+ label={t("more.appearance")}
+ onPress={() => router.push("/appearance")}
+ />
}
label={t("more.language")}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 97aa5d3..7de3868 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -6,12 +6,14 @@ import { Stack, useRouter, useSegments } from "expo-router";
import { StatusBar } from "expo-status-bar";
import * as Linking from "expo-linking";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { colorScheme } from "nativewind";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ObjectStackProvider } from "@objectstack/client-react";
import { authClient } from "~/lib/auth-client";
import { createObjectStackClient } from "~/lib/objectstack";
import { useServerStore } from "~/stores/server-store";
+import { useUIStore } from "~/stores/ui-store";
import { usePushNotifications } from "~/hooks/usePushNotifications";
import { ToastProvider } from "~/components/ui/Toast";
import { ConfirmProvider } from "~/components/ui/ConfirmDialog";
@@ -71,12 +73,21 @@ export default function RootLayout() {
const serverUrl = useServerStore((s) => s.serverUrl);
const isReady = useServerStore((s) => s.isReady);
const hydrate = useServerStore((s) => s.hydrate);
+ const themeMode = useUIStore((s) => s.theme);
// On mount, load the persisted server URL and re-target the auth/data clients.
useEffect(() => {
void hydrate();
}, [hydrate]);
+ // Apply the persisted color scheme from the root (always rendered) — the
+ // store's own module-load side-effect only runs once something imports it,
+ // which the first screen doesn't, so the saved theme wasn't applied on a cold
+ // start.
+ useEffect(() => {
+ colorScheme.set(themeMode);
+ }, [themeMode]);
+
useProtectedRoute(serverUrl, isReady);
const { data: session } = authClient.useSession();
@@ -130,6 +141,7 @@ export default function RootLayout() {
+
diff --git a/app/appearance.tsx b/app/appearance.tsx
new file mode 100644
index 0000000..3c24a53
--- /dev/null
+++ b/app/appearance.tsx
@@ -0,0 +1,21 @@
+import { ScrollView } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useTranslation } from "react-i18next";
+import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { ThemeSelector } from "~/components/common/ThemeSelector";
+
+/**
+ * Appearance — switch the app's color scheme (light / dark / system). Backed by
+ * `useUIStore.setTheme` → NativeWind, applied live across every screen.
+ */
+export default function AppearanceScreen() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/components/common/ThemeSelector.tsx b/components/common/ThemeSelector.tsx
new file mode 100644
index 0000000..8ec9a51
--- /dev/null
+++ b/components/common/ThemeSelector.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { View, Text, Pressable } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Check, Sun, Moon, SmartphoneNfc } from "lucide-react-native";
+import * as Haptics from "expo-haptics";
+import { useUIStore, type ThemeMode } from "~/stores/ui-store";
+import { cn } from "~/lib/utils";
+
+const OPTIONS: { mode: ThemeMode; icon: typeof Sun; labelKey: string }[] = [
+ { mode: "light", icon: Sun, labelKey: "appearance.light" },
+ { mode: "dark", icon: Moon, labelKey: "appearance.dark" },
+ { mode: "system", icon: SmartphoneNfc, labelKey: "appearance.system" },
+];
+
+/**
+ * Theme/appearance selector — light / dark / system. Mirrors LanguageSelector;
+ * writes through `useUIStore.setTheme`, which applies the NativeWind color
+ * scheme live and persists the choice.
+ */
+export function ThemeSelector({ className }: { className?: string }) {
+ const { t } = useTranslation();
+ const theme = useUIStore((s) => s.theme);
+ const setTheme = useUIStore((s) => s.setTheme);
+
+ return (
+
+
+ {t("appearance.title")}
+
+ {OPTIONS.map(({ mode, icon: Icon, labelKey }) => {
+ const isActive = theme === mode;
+ return (
+ {
+ if (!isActive) {
+ void Haptics.selectionAsync();
+ setTheme(mode);
+ }
+ }}
+ >
+
+
+
+ {t(labelKey)}
+
+
+ {isActive && }
+
+ );
+ })}
+
+ );
+}
diff --git a/jest.setup.ts b/jest.setup.ts
index 2381875..e3fa0f1 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -9,6 +9,23 @@ jest.mock("react-native-css-interop", () => ({
remapProps: jest.fn(),
}));
+/* ---- nativewind (color-scheme API used by ui-store / layouts) ---- */
+jest.mock("nativewind", () => {
+ let scheme: "light" | "dark" | "system" = "system";
+ return {
+ colorScheme: {
+ set: jest.fn((v: "light" | "dark" | "system") => {
+ scheme = v;
+ }),
+ get: jest.fn(() => scheme),
+ },
+ useColorScheme: () => ({
+ colorScheme: scheme === "system" ? "light" : scheme,
+ setColorScheme: jest.fn(),
+ }),
+ };
+});
+
/* ---- expo/fetch (streaming fetch — native module, unloadable in Node) ---- */
jest.mock("expo/fetch", () => ({ fetch: jest.fn() }));
diff --git a/locales/ar.json b/locales/ar.json
index fab6bf7..a58ac14 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -172,7 +172,8 @@
"approvals": "الموافقات",
"flows": "التدفقات",
"language": "اللغة",
- "signOut": "تسجيل الخروج"
+ "signOut": "تسجيل الخروج",
+ "appearance": "المظهر"
},
"ai": {
"title": "المساعد الذكي",
@@ -251,5 +252,11 @@
"dashboard": "لوحة التحكم",
"noWidgets": "لم يتم تهيئة أي أدوات.",
"refreshing": "جارٍ التحديث…"
+ },
+ "appearance": {
+ "title": "المظهر",
+ "light": "فاتح",
+ "dark": "داكن",
+ "system": "اتباع النظام"
}
}
diff --git a/locales/en.json b/locales/en.json
index 0c3ab2e..ba55de8 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -169,7 +169,8 @@
"approvals": "Approvals",
"flows": "Flows",
"language": "Language",
- "signOut": "Sign Out"
+ "signOut": "Sign Out",
+ "appearance": "Appearance"
},
"ai": {
"title": "AI Assistant",
@@ -240,5 +241,11 @@
"dashboard": "Dashboard",
"noWidgets": "No widgets configured.",
"refreshing": "Refreshing…"
+ },
+ "appearance": {
+ "title": "Appearance",
+ "light": "Light",
+ "dark": "Dark",
+ "system": "System"
}
}
diff --git a/locales/zh.json b/locales/zh.json
index 6784f44..e468f2d 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -167,7 +167,8 @@
"approvals": "审批",
"flows": "流程",
"language": "语言",
- "signOut": "退出登录"
+ "signOut": "退出登录",
+ "appearance": "外观"
},
"ai": {
"title": "AI 助手",
@@ -236,5 +237,11 @@
"dashboard": "仪表盘",
"noWidgets": "未配置小部件。",
"refreshing": "刷新中…"
+ },
+ "appearance": {
+ "title": "外观",
+ "light": "浅色",
+ "dark": "深色",
+ "system": "跟随系统"
}
}
diff --git a/stores/ui-store.ts b/stores/ui-store.ts
index 631425a..89feace 100644
--- a/stores/ui-store.ts
+++ b/stores/ui-store.ts
@@ -1,21 +1,44 @@
import { create } from "zustand";
+import { colorScheme } from "nativewind";
+import { createMMKV } from "react-native-mmkv";
import i18n from "~/lib/i18n";
import type { SupportedLanguage } from "~/lib/i18n";
+export type ThemeMode = "light" | "dark" | "system";
+
+const storage = createMMKV({ id: "objectstack-ui" });
+const THEME_KEY = "theme";
+
+function loadTheme(): ThemeMode {
+ const v = storage.getString(THEME_KEY);
+ return v === "light" || v === "dark" || v === "system" ? v : "system";
+}
+
interface UIState {
/** Current theme mode */
- theme: "light" | "dark" | "system";
- /** Set theme */
- setTheme: (theme: "light" | "dark" | "system") => void;
+ theme: ThemeMode;
+ /** Set theme — applies the NativeWind color scheme and persists it */
+ setTheme: (theme: ThemeMode) => void;
/** Current language code */
language: SupportedLanguage;
/** Change language (updates i18next and store) */
setLanguage: (lang: SupportedLanguage) => void;
}
+const initialTheme = loadTheme();
+
+// Apply the persisted theme up front so the first paint matches the user's
+// choice. `setTheme` previously only set state and never touched NativeWind, so
+// the theme was inert and dark mode was unreachable.
+colorScheme.set(initialTheme);
+
export const useUIStore = create((set) => ({
- theme: "system",
- setTheme: (theme) => set({ theme }),
+ theme: initialTheme,
+ setTheme: (theme) => {
+ colorScheme.set(theme);
+ storage.set(THEME_KEY, theme);
+ set({ theme });
+ },
language: (i18n.language ?? "en") as SupportedLanguage,
setLanguage: (lang) => {
i18n.changeLanguage(lang);