Skip to content

Commit c828935

Browse files
dfallingclaude
andauthored
Add system-driven dark mode (#36)
The app honored the OS color scheme only for the status bar; every surface was hardcoded to a light palette, which looked broken under a dark system theme. Introduce a small theme layer (light/dark semantic tokens + a useTheme hook over useColorScheme) and route every screen's colors through it, so appearance follows the system setting with no in-app toggle. The map is the centerpiece: it now swaps the MapLibre style URL to OpenFreeMap's dark basemap and recolors pins/controls for contrast against it. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c095f99 commit c828935

12 files changed

Lines changed: 983 additions & 749 deletions

App.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,38 @@
55
*/
66

77
import {ApolloProvider} from '@apollo/client';
8-
import {NavigationContainer} from '@react-navigation/native';
9-
import {useEffect} from 'react';
108
import {
11-
ActivityIndicator,
12-
StatusBar,
13-
StyleSheet,
14-
useColorScheme,
15-
View,
16-
} from 'react-native';
9+
DarkTheme,
10+
DefaultTheme,
11+
NavigationContainer,
12+
} from '@react-navigation/native';
13+
import {useEffect} from 'react';
14+
import {ActivityIndicator, StatusBar, StyleSheet, View} from 'react-native';
1715
import {SafeAreaProvider} from 'react-native-safe-area-context';
1816
import {apolloClient} from './src/auth/authClient';
1917
import {useDeepLinkListener} from './src/auth/deepLinks';
2018
import {LoginScreen} from './src/auth/LoginScreen';
2119
import {tokenStore, useAuth, useAuthHydrated} from './src/auth/tokenStore';
2220
import {RootNavigator} from './src/navigation/RootNavigator';
21+
import {useTheme} from './src/theme/useTheme';
2322

2423
function App() {
25-
const isDarkMode = useColorScheme() === 'dark';
24+
const theme = useTheme();
2625

2726
return (
2827
<ApolloProvider client={apolloClient}>
2928
<SafeAreaProvider>
30-
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
29+
<StatusBar
30+
barStyle={theme.scheme === 'dark' ? 'light-content' : 'dark-content'}
31+
/>
3132
<AppContent />
3233
</SafeAreaProvider>
3334
</ApolloProvider>
3435
);
3536
}
3637

3738
function AppContent() {
39+
const theme = useTheme();
3840
const hydrated = useAuthHydrated();
3941
const auth = useAuth();
4042

@@ -46,7 +48,12 @@ function AppContent() {
4648

4749
if (!hydrated) {
4850
return (
49-
<View style={[styles.container, styles.splash]}>
51+
<View
52+
style={[
53+
styles.container,
54+
styles.splash,
55+
{backgroundColor: theme.background},
56+
]}>
5057
<ActivityIndicator />
5158
</View>
5259
);
@@ -56,8 +63,12 @@ function AppContent() {
5663
return <LoginScreen />;
5764
}
5865

66+
// Match React Navigation's container background to the active scheme so the
67+
// gap shown during the slide transition between screens isn't a white flash
68+
// in dark mode.
5969
return (
60-
<NavigationContainer>
70+
<NavigationContainer
71+
theme={theme.scheme === 'dark' ? DarkTheme : DefaultTheme}>
6172
<RootNavigator />
6273
</NavigationContainer>
6374
);

src/account/AccountMenu.tsx

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import {useState} from 'react';
1+
import {useMemo, useState} from 'react';
22
import {Pressable, StyleSheet, Text} from 'react-native';
33
import {useSafeAreaInsets} from 'react-native-safe-area-context';
44
import {logout} from '../auth/authClient';
55
import {useAuth} from '../auth/tokenStore';
6+
import type {Theme} from '../theme/colors';
7+
import {useTheme} from '../theme/useTheme';
68
import {Sheet} from '../ui/Sheet';
79

810
/**
911
* Account avatar + sheet, pinned top-right. Lives inside the map screen (rather
1012
* than as a root-level sibling) so pushed detail screens cover it.
1113
*/
1214
export function AccountMenu() {
15+
const theme = useTheme();
16+
const styles = useMemo(() => makeStyles(theme), [theme]);
1317
const safeAreaInsets = useSafeAreaInsets();
1418
const [open, setOpen] = useState(false);
1519
const auth = useAuth();
@@ -57,44 +61,45 @@ export function AccountMenu() {
5761
);
5862
}
5963

60-
const styles = StyleSheet.create({
61-
avatarButton: {
62-
position: 'absolute',
63-
right: 16,
64-
width: 40,
65-
height: 40,
66-
borderRadius: 20,
67-
backgroundColor: '#1d6fe0',
68-
alignItems: 'center',
69-
justifyContent: 'center',
70-
shadowColor: '#000',
71-
shadowOpacity: 0.2,
72-
shadowRadius: 4,
73-
shadowOffset: {width: 0, height: 2},
74-
elevation: 4,
75-
},
76-
avatarPressed: {opacity: 0.8},
77-
avatarText: {
78-
color: '#ffffff',
79-
fontSize: 16,
80-
fontWeight: '600',
81-
},
82-
sheetEmail: {
83-
fontSize: 12,
84-
color: '#666',
85-
paddingHorizontal: 12,
86-
paddingBottom: 8,
87-
},
88-
sheetItem: {
89-
paddingHorizontal: 12,
90-
paddingVertical: 14,
91-
borderRadius: 8,
92-
},
93-
sheetItemPressed: {
94-
backgroundColor: '#f2f2f2',
95-
},
96-
sheetItemText: {
97-
fontSize: 16,
98-
color: '#222',
99-
},
100-
});
64+
const makeStyles = (theme: Theme) =>
65+
StyleSheet.create({
66+
avatarButton: {
67+
position: 'absolute',
68+
right: 16,
69+
width: 40,
70+
height: 40,
71+
borderRadius: 20,
72+
backgroundColor: theme.accent,
73+
alignItems: 'center',
74+
justifyContent: 'center',
75+
shadowColor: '#000',
76+
shadowOpacity: 0.2,
77+
shadowRadius: 4,
78+
shadowOffset: {width: 0, height: 2},
79+
elevation: 4,
80+
},
81+
avatarPressed: {opacity: 0.8},
82+
avatarText: {
83+
color: '#ffffff',
84+
fontSize: 16,
85+
fontWeight: '600',
86+
},
87+
sheetEmail: {
88+
fontSize: 12,
89+
color: theme.textSecondary,
90+
paddingHorizontal: 12,
91+
paddingBottom: 8,
92+
},
93+
sheetItem: {
94+
paddingHorizontal: 12,
95+
paddingVertical: 14,
96+
borderRadius: 8,
97+
},
98+
sheetItemPressed: {
99+
backgroundColor: theme.surfaceMuted,
100+
},
101+
sheetItemText: {
102+
fontSize: 16,
103+
color: theme.textPrimary,
104+
},
105+
});

src/auth/LoginScreen.tsx

Lines changed: 59 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useState} from 'react';
1+
import {useMemo, useState} from 'react';
22
import {
33
ActivityIndicator,
44
KeyboardAvoidingView,
@@ -10,6 +10,8 @@ import {
1010
View,
1111
} from 'react-native';
1212
import {useLoginMutation} from '../graphql/__generated__/types';
13+
import type {Theme} from '../theme/colors';
14+
import {useTheme} from '../theme/useTheme';
1315
import {clearSecurityWarning, useSecurityWarning} from './authClient';
1416
import {tokenStore} from './tokenStore';
1517

@@ -43,6 +45,8 @@ function messageForCode(code: string | undefined): string {
4345
}
4446

4547
export function LoginScreen() {
48+
const theme = useTheme();
49+
const styles = useMemo(() => makeStyles(theme), [theme]);
4650
const [email, setEmail] = useState('');
4751
const [password, setPassword] = useState('');
4852
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -83,6 +87,7 @@ export function LoginScreen() {
8387
<TextInput
8488
style={styles.input}
8589
placeholder="Email"
90+
placeholderTextColor={theme.textTertiary}
8691
autoCapitalize="none"
8792
autoCorrect={false}
8893
autoComplete="email"
@@ -94,6 +99,7 @@ export function LoginScreen() {
9499
<TextInput
95100
style={styles.input}
96101
placeholder="Password"
102+
placeholderTextColor={theme.textTertiary}
97103
autoCapitalize="none"
98104
autoCorrect={false}
99105
autoComplete="password"
@@ -119,7 +125,7 @@ export function LoginScreen() {
119125
pressed && canSubmit && styles.buttonPressed,
120126
]}>
121127
{loading ? (
122-
<ActivityIndicator color="#fff" />
128+
<ActivityIndicator color={theme.onAction} />
123129
) : (
124130
<Text style={styles.buttonText}>Log in</Text>
125131
)}
@@ -129,51 +135,54 @@ export function LoginScreen() {
129135
);
130136
}
131137

132-
const styles = StyleSheet.create({
133-
flex: {flex: 1},
134-
container: {
135-
flex: 1,
136-
padding: 24,
137-
justifyContent: 'center',
138-
gap: 12,
139-
},
140-
title: {
141-
fontSize: 24,
142-
fontWeight: '600',
143-
marginBottom: 16,
144-
textAlign: 'center',
145-
},
146-
input: {
147-
borderWidth: 1,
148-
borderColor: '#ccc',
149-
borderRadius: 8,
150-
paddingHorizontal: 12,
151-
paddingVertical: 12,
152-
fontSize: 16,
153-
},
154-
error: {
155-
color: '#c00',
156-
fontSize: 14,
157-
textAlign: 'center',
158-
},
159-
warning: {
160-
color: '#a55',
161-
fontSize: 13,
162-
textAlign: 'center',
163-
fontStyle: 'italic',
164-
},
165-
button: {
166-
backgroundColor: '#0a7ea4',
167-
borderRadius: 8,
168-
paddingVertical: 14,
169-
alignItems: 'center',
170-
marginTop: 8,
171-
},
172-
buttonDisabled: {opacity: 0.5},
173-
buttonPressed: {opacity: 0.85},
174-
buttonText: {
175-
color: '#fff',
176-
fontSize: 16,
177-
fontWeight: '600',
178-
},
179-
});
138+
const makeStyles = (theme: Theme) =>
139+
StyleSheet.create({
140+
flex: {flex: 1, backgroundColor: theme.background},
141+
container: {
142+
flex: 1,
143+
padding: 24,
144+
justifyContent: 'center',
145+
gap: 12,
146+
},
147+
title: {
148+
fontSize: 24,
149+
fontWeight: '600',
150+
marginBottom: 16,
151+
textAlign: 'center',
152+
color: theme.textPrimary,
153+
},
154+
input: {
155+
borderWidth: 1,
156+
borderColor: theme.border,
157+
borderRadius: 8,
158+
paddingHorizontal: 12,
159+
paddingVertical: 12,
160+
fontSize: 16,
161+
color: theme.textPrimary,
162+
},
163+
error: {
164+
color: theme.error,
165+
fontSize: 14,
166+
textAlign: 'center',
167+
},
168+
warning: {
169+
color: theme.error,
170+
fontSize: 13,
171+
textAlign: 'center',
172+
fontStyle: 'italic',
173+
},
174+
button: {
175+
backgroundColor: theme.action,
176+
borderRadius: 8,
177+
paddingVertical: 14,
178+
alignItems: 'center',
179+
marginTop: 8,
180+
},
181+
buttonDisabled: {opacity: 0.5},
182+
buttonPressed: {opacity: 0.85},
183+
buttonText: {
184+
color: theme.onAction,
185+
fontSize: 16,
186+
fontWeight: '600',
187+
},
188+
});

0 commit comments

Comments
 (0)