Files
bincio-rec/src/screens/SettingsScreen.tsx
T
Davide Scaini 8cc2b07b1f feat: multiple map tile styles switchable in Settings > Interface
New src/mapStyles.ts defines five styles from bincio_planner's sources:
- Liberty (OpenFreeMap vector, default)
- CyclOSM (cycling infrastructure raster)
- Topo (OpenTopoMap elevation raster)
- Satellite (Esri World Imagery raster)
- OSM (standard raster fallback)

Raster sources are wrapped in a StyleSpecification so MapLibre handles
them natively. Setting persisted via ThemeContext (AsyncStorage key
mapTileStyle). RecordingScreen and ActivityDetailScreen both read from
context so the style updates everywhere simultaneously.

MAP_STRATEGY.md added (untracked) with full map roadmap.
2026-06-04 00:52:58 +02:00

264 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import {
View, Text, StyleSheet, Switch,
TouchableOpacity, Alert, ScrollView, ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { login, logout, loadAuthState } from '../services/auth';
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
import { useTheme, type MapOrientation } from '../ThemeContext';
import { MAP_STYLES, MAP_TILE_STYLE_ORDER } from '../mapStyles';
type Tab = 'ui' | 'app' | 'sync';
export function SettingsScreen() {
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, mapTileStyle, setMapTileStyle } = 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}>Map style</Text>
{MAP_TILE_STYLE_ORDER.map((key) => {
const def = MAP_STYLES[key];
const active = mapTileStyle === key;
return (
<TouchableOpacity
key={key}
style={[styles.row, active && { borderColor: accent, backgroundColor: PALETTES[palette].accentDim }]}
onPress={() => setMapTileStyle(key)}
>
<View>
<Text style={[styles.rowLabel, active && { color: accent }]}>{def.label}</Text>
<Text style={styles.rowSub}>{def.description}</Text>
</View>
{active && <Text style={{ color: accent, fontSize: 16 }}></Text>}
</TouchableOpacity>
);
})}
<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 ─────────────────────────────────────────────────────────────────
const MAP_ORIENTATION_OPTIONS: { key: MapOrientation; label: string; sub: string }[] = [
{ key: 'north', label: 'North up', sub: 'Map always points north' },
{ key: 'compass', label: 'Compass', sub: 'Rotates with device heading' },
{ key: 'course', label: 'Course up', sub: 'Rotates to direction of travel' },
];
function AppTab() {
const { accent, accentDim, mapOrientation, setMapOrientation } = useTheme();
const [kmNotifications, setKmNotifications] = useState(true);
useEffect(() => {
AsyncStorage.getItem('kmNotifications').then((v) => {
if (v !== null) setKmNotifications(v === 'true');
});
}, []);
async function handleKmToggle(value: boolean) {
setKmNotifications(value);
await AsyncStorage.setItem('kmNotifications', String(value));
}
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Map orientation</Text>
{MAP_ORIENTATION_OPTIONS.map(({ key, label, sub }) => {
const active = mapOrientation === key;
return (
<TouchableOpacity
key={key}
style={[styles.row, active && { borderColor: accent, backgroundColor: accentDim }]}
onPress={() => setMapOrientation(key)}
>
<View>
<Text style={[styles.rowLabel, active && { color: accent }]}>{label}</Text>
<Text style={styles.rowSub}>{sub}</Text>
</View>
{active && <Text style={{ color: accent, fontSize: 16 }}></Text>}
</TouchableOpacity>
);
})}
<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 [connectedAs, setConnectedAs] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
useEffect(() => {
loadAuthState().then((auth) => {
if (auth) setConnectedAs(auth.handle);
});
}, []);
async function handleConnect() {
setConnecting(true);
const result = await login();
setConnecting(false);
if (result.ok) setConnectedAs(result.displayName ?? '');
else if (result.error !== 'Cancelled') Alert.alert('Sign in 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);
}},
]);
}
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>bincio instance</Text>
{connectedAs ? (
<View style={styles.connectedBox}>
<View>
<Text style={[styles.connectedLabel, { color: colors.success }]}>Connected</Text>
<Text style={styles.connectedName}>{connectedAs}</Text>
</View>
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
<Text style={styles.disconnectBtnText}>Disconnect</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={styles.hint}>Sign in with your bincio account to sync recordings.</Text>
<TouchableOpacity style={[styles.connectBtn, connecting && styles.connectBtnDisabled]} onPress={handleConnect} disabled={connecting}>
{connecting ? <ActivityIndicator color={colors.text} /> : <Text style={styles.connectBtnText}>Sign in with bincio</Text>}
</TouchableOpacity>
</>
)}
<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 },
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' },
});