feat(mobile): Implement persistent theme selection
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
60
MobileApp/context/ThemeContext.tsx
Normal file
60
MobileApp/context/ThemeContext.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
import { useTheme } from '@/context/ThemeContext';
|
||||||
|
|
||||||
|
export function useColorScheme() {
|
||||||
|
const { colorScheme } = useTheme();
|
||||||
|
return colorScheme;
|
||||||
|
}
|
||||||
|
|||||||
34
MobileApp/package-lock.json
generated
34
MobileApp/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user