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:
+183
-107
@@ -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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user