8cc2b07b1f
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.
264 lines
12 KiB
TypeScript
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' },
|
|
});
|