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);