feat(mobile): Implement persistent theme selection

This commit is contained in:
2025-12-06 14:08:00 +00:00
parent ed0faaa4d6
commit 19ff225762
7 changed files with 146 additions and 19 deletions

View File

@@ -1,12 +1,15 @@
import { StyleSheet } from 'react-native'; import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme'; import { Colors } from '@/constants/theme';
import { useTheme } from '@/context/ThemeContext';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
export default function SettingsScreen() { export default function SettingsScreen() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const { themeMode, setThemeMode } = useTheme();
const theme = colorScheme ?? 'light'; const theme = colorScheme ?? 'light';
const cardStyle = [ const cardStyle = [
@@ -25,21 +28,32 @@ export default function SettingsScreen() {
</ThemedView> </ThemedView>
<ThemedView style={styles.sectionHeader}> <ThemedView style={styles.sectionHeader}>
<ThemedText type="subtitle">Account</ThemedText> <ThemedText type="subtitle">Appearance</ThemedText>
</ThemedView> </ThemedView>
<ThemedView style={cardStyle} variant="card"> <ThemedView style={cardStyle} variant="card">
<ThemedText style={styles.item}>Profile</ThemedText> <TouchableOpacity
<ThemedText style={styles.item}>Notifications</ThemedText> style={styles.item}
<ThemedText style={[styles.item, styles.lastItem]}>Privacy</ThemedText> onPress={() => setThemeMode('light')}
</ThemedView> >
<ThemedText>Light Mode</ThemedText>
{themeMode === 'light' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
</TouchableOpacity>
<ThemedView style={styles.sectionHeader}> <TouchableOpacity
<ThemedText type="subtitle">General</ThemedText> style={styles.item}
</ThemedView> onPress={() => setThemeMode('dark')}
<ThemedView style={cardStyle} variant="card"> >
<ThemedText style={styles.item}>About</ThemedText> <ThemedText>Dark Mode</ThemedText>
<ThemedText style={styles.item}>Help & Support</ThemedText> {themeMode === 'dark' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
<ThemedText style={[styles.item, styles.lastItem]}>Sign Out</ThemedText> </TouchableOpacity>
<TouchableOpacity
style={[styles.item, styles.lastItem]}
onPress={() => setThemeMode('system')}
>
<ThemedText>System Default</ThemedText>
{themeMode === 'system' && <IconSymbol name="checkmark" size={20} color={Colors[theme].tint} />}
</TouchableOpacity>
</ThemedView> </ThemedView>
</ThemedView> </ThemedView>
); );
@@ -71,6 +85,9 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(0,0,0,0.05)', borderBottomColor: 'rgba(0,0,0,0.05)',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}, },
lastItem: { lastItem: {
borderBottomWidth: 0, borderBottomWidth: 0,

View File

@@ -1,8 +1,9 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { ThemeProvider } from '@/context/ThemeContext';
import { useColorScheme } from '@/hooks/use-color-scheme'; import { useColorScheme } from '@/hooks/use-color-scheme';
export const unstable_settings = { export const unstable_settings = {
@@ -10,15 +11,23 @@ export const unstable_settings = {
}; };
export default function RootLayout() { export default function RootLayout() {
return (
<ThemeProvider>
<RootLayoutNav />
</ThemeProvider>
);
}
function RootLayoutNav() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <NavigationThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack> <Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</ThemeProvider> </NavigationThemeProvider>
); );
} }

View File

@@ -21,6 +21,7 @@ const MAPPING = {
'bell.fill': 'notifications', 'bell.fill': 'notifications',
'film.fill': 'video-library', 'film.fill': 'video-library',
'gearshape.fill': 'settings', 'gearshape.fill': 'settings',
'checkmark': 'check',
} as IconMapping; } as IconMapping;
/** /**

View File

@@ -0,0 +1,60 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useColorScheme as useNativeColorScheme } from 'react-native';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
colorScheme: 'light' | 'dark'; // The actual active scheme
}
const ThemeContext = createContext<ThemeContextType>({
themeMode: 'system',
setThemeMode: () => { },
colorScheme: 'light',
});
export const THEME_STORAGE_KEY = 'user_theme_preference';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemColorScheme = useNativeColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Load preference from storage on mount
AsyncStorage.getItem(THEME_STORAGE_KEY).then((value) => {
if (value && (value === 'light' || value === 'dark' || value === 'system')) {
setThemeModeState(value as ThemeMode);
}
setIsReady(true);
});
}, []);
const setThemeMode = async (mode: ThemeMode) => {
setThemeModeState(mode);
await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
};
// Determine effective color scheme
const colorScheme =
themeMode === 'system'
? (systemColorScheme ?? 'light')
: themeMode;
if (!isReady) {
return null; // Or a splash screen
}
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode, colorScheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

View File

@@ -1 +1,6 @@
export { useColorScheme } from 'react-native'; import { useTheme } from '@/context/ThemeContext';
export function useColorScheme() {
const { colorScheme } = useTheme();
return colorScheme;
}

View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -2777,6 +2778,18 @@
} }
} }
}, },
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native/assets-registry": { "node_modules/@react-native/assets-registry": {
"version": "0.81.5", "version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -7810,6 +7823,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -8788,6 +8810,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

View File

@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -31,11 +32,11 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-worklets": "0.5.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0" "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",