feat: ThemeContext + Settings tabs (Interface / App / Sync)

- ThemeContext: dynamic palette (Default/Giro/Tour/Vuelta), font size
  (small/medium/large), bold labels — all persisted to AsyncStorage
- Settings: three top tabs; Interface tab has palette picker + font
  size pills + bold labels toggle; App tab has km notifications;
  Sync tab has bincio instance login + autarchive placeholder
- RecordingScreen: stat labels now use theme accent colour and scale
  with fontSize; font weight follows boldLabels setting
- All accent/accentDim usages migrated from static colors to useTheme()
This commit is contained in:
Davide Scaini
2026-06-03 10:00:27 +02:00
parent ea938e5644
commit efc7af4a4a
8 changed files with 366 additions and 197 deletions
+183 -107
View File
@@ -5,71 +5,166 @@ import {
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { login, logout, loadAuthState } from '../services/auth';
import { colors } from '../theme';
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
import { useTheme } from '../ThemeContext';
type Tab = 'ui' | 'app' | 'sync';
export function SettingsScreen() {
const [instanceUrl, setInstanceUrl] = useState('');
const [handle, setHandle] = useState('');
const [password, setPassword] = useState('');
const [connectedAs, setConnectedAs] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [tab, setTab] = useState<Tab>('ui');
const { accent } = useTheme();
return (
<View style={styles.container}>
{/* Tab bar */}
<View style={styles.tabBar}>
{(['ui', 'app', 'sync'] as Tab[]).map((t) => (
<TouchableOpacity
key={t}
style={[styles.tabBtn, tab === t && { borderBottomColor: accent, borderBottomWidth: 2 }]}
onPress={() => setTab(t)}
>
<Text style={[styles.tabLabel, tab === t && { color: accent }]}>
{t === 'ui' ? 'Interface' : t === 'app' ? 'App' : 'Sync'}
</Text>
</TouchableOpacity>
))}
</View>
{tab === 'ui' && <UITab />}
{tab === 'app' && <AppTab />}
{tab === 'sync' && <SyncTab />}
</View>
);
}
// ─── UI tab ─────────────────────────────────────────────────────────────────
function UITab() {
const { accent, palette, setPalette, fontSize, setFontSize, boldLabels, setBoldLabels } = useTheme();
const fontSizes: FontSizeKey[] = ['small', 'medium', 'large'];
const palettes = Object.entries(PALETTES) as [PaletteKey, typeof PALETTES[PaletteKey]][];
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Colour palette</Text>
<View style={styles.pillRow}>
{palettes.map(([key, val]) => (
<TouchableOpacity
key={key}
style={[styles.pill, palette === key && { borderColor: val.accent, backgroundColor: val.accentDim }]}
onPress={() => setPalette(key)}
>
<View style={[styles.paletteDot, { backgroundColor: val.accent }]} />
<Text style={[styles.pillText, palette === key && { color: val.accent }]}>{val.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Font size</Text>
<View style={styles.pillRow}>
{fontSizes.map((s) => (
<TouchableOpacity
key={s}
style={[styles.pill, fontSize === s && { borderColor: accent, backgroundColor: PALETTES[palette].accentDim }]}
onPress={() => setFontSize(s)}
>
<Text style={[styles.pillText, fontSize === s && { color: accent }]}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Stat labels</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Bold labels</Text>
<Switch
value={boldLabels}
onValueChange={setBoldLabels}
trackColor={{ true: accent }}
thumbColor={colors.text}
/>
</View>
</ScrollView>
);
}
// ─── App tab ─────────────────────────────────────────────────────────────────
function AppTab() {
const { accent } = useTheme();
const [kmNotifications, setKmNotifications] = useState(true);
useEffect(() => {
(async () => {
const [auth, km] = await Promise.all([
loadAuthState(),
AsyncStorage.getItem('kmNotifications'),
]);
if (auth) {
setInstanceUrl(auth.instanceUrl);
setHandle(auth.handle);
setConnectedAs(auth.handle);
}
if (km !== null) setKmNotifications(km === 'true');
})();
AsyncStorage.getItem('kmNotifications').then((v) => {
if (v !== null) setKmNotifications(v === 'true');
});
}, []);
async function handleConnect() {
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
setConnecting(true);
const result = await login(instanceUrl.trim(), handle.trim(), password);
setConnecting(false);
if (result.ok) {
setConnectedAs(result.displayName || handle.trim());
setPassword('');
} else {
Alert.alert('Login failed', result.error ?? 'Unknown error');
}
}
async function handleDisconnect() {
Alert.alert('Disconnect', 'Remove saved credentials?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disconnect',
style: 'destructive',
onPress: async () => {
await logout();
setConnectedAs(null);
setHandle('');
setPassword('');
},
},
]);
}
async function handleKmToggle(value: boolean) {
setKmNotifications(value);
await AsyncStorage.setItem('kmNotifications', String(value));
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Notifications</Text>
<View style={styles.row}>
<View>
<Text style={styles.rowLabel}>Kilometre alerts</Text>
<Text style={styles.rowSub}>Notify at every km during recording</Text>
</View>
<Switch
value={kmNotifications}
onValueChange={handleKmToggle}
trackColor={{ true: accent }}
thumbColor={colors.text}
/>
</View>
</ScrollView>
);
}
// ─── Sync tab ─────────────────────────────────────────────────────────────────
function SyncTab() {
const { accent } = useTheme();
const [instanceUrl, setInstanceUrl] = useState('');
const [handle, setHandle] = useState('');
const [password, setPassword] = useState('');
const [connectedAs, setConnectedAs] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
useEffect(() => {
loadAuthState().then((auth) => {
if (auth) { setInstanceUrl(auth.instanceUrl); setHandle(auth.handle); setConnectedAs(auth.handle); }
});
}, []);
async function handleConnect() {
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
setConnecting(true);
const result = await login(instanceUrl.trim(), handle.trim(), password);
setConnecting(false);
if (result.ok) { setConnectedAs(result.displayName || handle.trim()); setPassword(''); }
else Alert.alert('Login failed', result.error ?? 'Unknown error');
}
async function handleDisconnect() {
Alert.alert('Disconnect', 'Remove saved credentials?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
await logout(); setConnectedAs(null); setHandle(''); setPassword('');
}},
]);
}
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>bincio instance</Text>
<Text style={styles.label}>Instance URL</Text>
@@ -87,7 +182,7 @@ export function SettingsScreen() {
{connectedAs ? (
<View style={styles.connectedBox}>
<View>
<Text style={styles.connectedLabel}>Connected</Text>
<Text style={[styles.connectedLabel, { color: colors.success }]}>Connected</Text>
<Text style={styles.connectedName}>{connectedAs}</Text>
</View>
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
@@ -97,72 +192,53 @@ export function SettingsScreen() {
) : (
<>
<Text style={styles.label}>Handle</Text>
<TextInput
style={styles.input}
value={handle}
onChangeText={setHandle}
placeholder="your-handle"
placeholderTextColor={colors.placeholder}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput style={styles.input} value={handle} onChangeText={setHandle} placeholder="your-handle" placeholderTextColor={colors.placeholder} autoCapitalize="none" autoCorrect={false} />
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
placeholderTextColor={colors.placeholder}
secureTextEntry
/>
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="••••••••" placeholderTextColor={colors.placeholder} secureTextEntry />
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
</Text>
<Text style={styles.hint}>Your password is used once to obtain a session token, then forgotten.</Text>
<TouchableOpacity
style={[styles.connectBtn, connecting && styles.connectBtnDisabled]}
onPress={handleConnect}
disabled={connecting}
>
{connecting
? <ActivityIndicator color={colors.text} />
: <Text style={styles.connectBtnText}>Connect</Text>}
<TouchableOpacity style={[styles.connectBtn, connecting && styles.connectBtnDisabled]} onPress={handleConnect} disabled={connecting}>
{connecting ? <ActivityIndicator color={colors.text} /> : <Text style={styles.connectBtnText}>Connect</Text>}
</TouchableOpacity>
</>
)}
<Text style={styles.sectionTitle}>Notifications</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Kilometre alerts</Text>
<Switch
value={kmNotifications}
onValueChange={handleKmToggle}
trackColor={{ true: colors.accent }}
thumbColor={colors.text}
/>
<Text style={styles.sectionTitle}>bincio-autarchive</Text>
<View style={[styles.row, { opacity: 0.5 }]}>
<Text style={styles.rowLabel}>Local sync</Text>
<Text style={styles.rowSub}>Coming soon</Text>
</View>
</ScrollView>
);
}
// ─── Shared styles ────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
content: { padding: 16, gap: 10 },
sectionTitle: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
label: { color: colors.textSub, fontSize: 13, marginBottom: 2 },
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
hint: { color: colors.textMuted, fontSize: 12, lineHeight: 18 },
connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
connectedLabel: { color: colors.success, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
connectedName: { color: colors.text, fontSize: 15, fontWeight: '600', marginTop: 2 },
disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: colors.errorBg },
disconnectBtnText: { color: colors.error, fontWeight: '600', fontSize: 13 },
connectBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4 },
connectBtnDisabled: { opacity: 0.5 },
connectBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
rowLabel: { color: colors.text, fontSize: 15 },
container: { flex: 1, backgroundColor: colors.bg },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: colors.border },
tabBtn: { flex: 1, alignItems: 'center', paddingVertical: 14, borderBottomWidth: 2, borderBottomColor: 'transparent' },
tabLabel: { color: colors.textMuted, fontSize: 13, fontWeight: '600' },
content: { padding: 16, gap: 10 },
sectionTitle: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
pill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong },
paletteDot: { width: 10, height: 10, borderRadius: 5 },
pillText: { color: colors.textSub, fontSize: 13, fontWeight: '500' },
label: { color: colors.textSub, fontSize: 13, marginBottom: 2 },
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
hint: { color: colors.textMuted, fontSize: 12, lineHeight: 18 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
rowLabel: { color: colors.text, fontSize: 15 },
rowSub: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
connectedLabel: { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
connectedName: { color: colors.text, fontSize: 15, fontWeight: '600', marginTop: 2 },
disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: colors.errorBg },
disconnectBtnText: { color: colors.error, fontWeight: '600', fontSize: 13 },
connectBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4 },
connectBtnDisabled:{ opacity: 0.5 },
connectBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
});