Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions app/(tabs)/more.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Workflow,
Inbox,
Sparkles,
Palette,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -144,6 +145,11 @@ export default function MoreScreen() {

{/* Preferences */}
<SectionHeader title={t("more.sectionPreferences")} />
<MenuItem
icon={<Palette size={20} color="#64748b" />}
label={t("more.appearance")}
onPress={() => router.push("/appearance")}
/>
<MenuItem
icon={<Globe size={20} color="#64748b" />}
label={t("more.language")}
Expand Down
12 changes: 12 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -130,6 +141,7 @@ export default function RootLayout() {
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(app)" />
<Stack.Screen name="account" />
<Stack.Screen name="appearance" />
<Stack.Screen name="ai" />
</Stack>
</ConfirmProvider>
Expand Down
21 changes: 21 additions & 0 deletions app/appearance.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
<ScreenHeader title={t("appearance.title")} />
<ScrollView className="flex-1" contentContainerClassName="px-5 pt-4">
<ThemeSelector />
</ScrollView>
</SafeAreaView>
);
}
66 changes: 66 additions & 0 deletions components/common/ThemeSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className={cn("gap-1", className)}>
<Text className="mb-2 text-sm font-medium text-muted-foreground">
{t("appearance.title")}
</Text>
{OPTIONS.map(({ mode, icon: Icon, labelKey }) => {
const isActive = theme === mode;
return (
<Pressable
key={mode}
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
accessibilityLabel={t(labelKey)}
className={cn(
"flex-row items-center justify-between rounded-lg px-4 py-3 active:opacity-70",
isActive ? "bg-primary/10" : "bg-card",
)}
onPress={() => {
if (!isActive) {
void Haptics.selectionAsync();
setTheme(mode);
}
}}
>
<View className="flex-row items-center gap-3">
<Icon size={20} color={isActive ? "#1e40af" : "#64748b"} />
<Text
className={cn(
"text-base",
isActive ? "font-semibold text-primary" : "text-foreground",
)}
>
{t(labelKey)}
</Text>
</View>
{isActive && <Check size={18} color="#1e40af" />}
</Pressable>
);
})}
</View>
);
}
17 changes: 17 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));

Expand Down
9 changes: 8 additions & 1 deletion locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@
"approvals": "الموافقات",
"flows": "التدفقات",
"language": "اللغة",
"signOut": "تسجيل الخروج"
"signOut": "تسجيل الخروج",
"appearance": "المظهر"
},
"ai": {
"title": "المساعد الذكي",
Expand Down Expand Up @@ -251,5 +252,11 @@
"dashboard": "لوحة التحكم",
"noWidgets": "لم يتم تهيئة أي أدوات.",
"refreshing": "جارٍ التحديث…"
},
"appearance": {
"title": "المظهر",
"light": "فاتح",
"dark": "داكن",
"system": "اتباع النظام"
}
}
9 changes: 8 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@
"approvals": "Approvals",
"flows": "Flows",
"language": "Language",
"signOut": "Sign Out"
"signOut": "Sign Out",
"appearance": "Appearance"
},
"ai": {
"title": "AI Assistant",
Expand Down Expand Up @@ -240,5 +241,11 @@
"dashboard": "Dashboard",
"noWidgets": "No widgets configured.",
"refreshing": "Refreshing…"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
}
9 changes: 8 additions & 1 deletion locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@
"approvals": "审批",
"flows": "流程",
"language": "语言",
"signOut": "退出登录"
"signOut": "退出登录",
"appearance": "外观"
},
"ai": {
"title": "AI 助手",
Expand Down Expand Up @@ -236,5 +237,11 @@
"dashboard": "仪表盘",
"noWidgets": "未配置小部件。",
"refreshing": "刷新中…"
},
"appearance": {
"title": "外观",
"light": "浅色",
"dark": "深色",
"system": "跟随系统"
}
}
33 changes: 28 additions & 5 deletions stores/ui-store.ts
Original file line number Diff line number Diff line change
@@ -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<UIState>((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);
Expand Down
Loading