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
+3 -1
View File
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider } from './src/ThemeContext';
import { AppNavigator } from './src/navigation/AppNavigator';
import { requestNotificationPermissions } from './src/services/gps';
import { promptBatteryOptimizationIfNeeded } from './src/services/batteryOptimization';
// Show notifications even when the app is in the foreground (iOS suppresses by default)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
@@ -24,8 +24,10 @@ export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<StatusBar style="light" />
<AppNavigator />
</ThemeProvider>
</GestureHandlerRootView>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
interface ThemeValue {
accent: string;
accentDim: string;
palette: PaletteKey;
setPalette: (p: PaletteKey) => void;
fontSize: FontSizeKey;
setFontSize: (s: FontSizeKey) => void;
boldLabels: boolean;
setBoldLabels: (b: boolean) => void;
scale: number;
}
const ThemeContext = createContext<ThemeValue>({
...PALETTES.default,
palette: 'default',
setPalette: () => {},
fontSize: 'medium',
setFontSize: () => {},
boldLabels: false,
setBoldLabels: () => {},
scale: 1,
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [palette, setPaletteState] = useState<PaletteKey>('default');
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
const [boldLabels, setBoldLabelsState] = useState(false);
useEffect(() => {
(async () => {
const [p, f, b] = await Promise.all([
AsyncStorage.getItem('themePalette'),
AsyncStorage.getItem('themeFontSize'),
AsyncStorage.getItem('themeBoldLabels'),
]);
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
if (b !== null) setBoldLabelsState(b === 'true');
})();
}, []);
function setPalette(p: PaletteKey) {
setPaletteState(p);
AsyncStorage.setItem('themePalette', p);
}
function setFontSize(f: FontSizeKey) {
setFontSizeState(f);
AsyncStorage.setItem('themeFontSize', f);
}
function setBoldLabels(b: boolean) {
setBoldLabelsState(b);
AsyncStorage.setItem('themeBoldLabels', String(b));
}
return (
<ThemeContext.Provider value={{
accent: PALETTES[palette].accent,
accentDim: PALETTES[palette].accentDim,
palette, setPalette,
fontSize, setFontSize,
boldLabels, setBoldLabels,
scale: FONT_SCALE[fontSize],
}}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
+3 -1
View File
@@ -11,6 +11,7 @@ import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { RootStackParamList, TabParamList } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>();
@@ -22,13 +23,14 @@ const TAB_ICONS: Record<string, string> = {
};
function Tabs() {
const { accent } = useTheme();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerStyle: { backgroundColor: colors.bg },
headerTintColor: colors.text,
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
tabBarActiveTintColor: colors.accent,
tabBarActiveTintColor: accent,
tabBarInactiveTintColor: colors.textMuted,
tabBarIcon: ({ color }) => (
<Text style={{ color, fontSize: 18 }}>{TAB_ICONS[route.name]}</Text>
+49 -51
View File
@@ -9,20 +9,15 @@ import { useRecordingStore } from '../store/recording';
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
import { RootStackParamList } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const trackLineStyle: LineLayerStyle = {
lineColor: colors.accent,
lineWidth: 3,
lineJoin: 'round',
lineCap: 'round',
};
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function RecordingScreen() {
const nav = useNavigation<Nav>();
const { accent, accentDim, scale, boldLabels } = useTheme();
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
const [stats, setStats] = useState(getStats());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -33,44 +28,48 @@ export function RecordingScreen() {
}, []);
useEffect(() => {
if (keepAwake && status === 'recording') {
activateKeepAwakeAsync();
} else {
deactivateKeepAwake();
}
if (keepAwake && status === 'recording') activateKeepAwakeAsync();
else deactivateKeepAwake();
}, [keepAwake, status]);
const trackLineStyle = useMemo<LineLayerStyle>(() => ({
lineColor: accent,
lineWidth: 3,
lineJoin: 'round',
lineCap: 'round',
}), [accent]);
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(() => ({
type: 'Feature',
geometry: { type: 'LineString', coordinates: trackPoints.map((p) => [p.lon, p.lat]) },
properties: {},
}), [trackPoints]);
const statLabelStyle = useMemo(() => ({
color: accent,
fontSize: Math.round(10 * scale),
fontWeight: boldLabels ? '700' as const : '500' as const,
textTransform: 'uppercase' as const,
letterSpacing: 0.5,
}), [accent, scale, boldLabels]);
const statValueStyle = useMemo(() => ({
color: colors.text,
fontSize: Math.round(17 * scale),
fontWeight: '600' as const,
marginTop: 2,
}), [scale]);
async function handleStart() {
const granted = await requestLocationPermissions();
if (!granted) {
Alert.alert('Permission required', 'Location permission is required to record.');
return;
}
if (!granted) { Alert.alert('Permission required', 'Location permission is required to record.'); return; }
start();
await startGpsRecording();
}
async function handlePause() {
await stopGpsRecording();
pause();
}
async function handleResume() {
resume();
await startGpsRecording();
}
async function handleStop() {
await stopGpsRecording();
stop();
nav.navigate('PostRecording');
}
async function handlePause() { await stopGpsRecording(); pause(); }
async function handleResume() { resume(); await startGpsRecording(); }
async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
const formatDuration = (secs: number) => {
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
@@ -82,14 +81,14 @@ export function RecordingScreen() {
return (
<View style={styles.container}>
<View style={styles.statsGrid}>
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} />
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} />
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} />
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} />
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
</View>
<View style={styles.mapArea}>
@@ -105,15 +104,14 @@ export function RecordingScreen() {
</GeoJSONSource>
)}
</Map>
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}>
<Text style={styles.sensorBtnText}> Sensors</Text>
<TouchableOpacity style={[styles.sensorBtn, { borderColor: colors.border }]} onPress={() => nav.navigate('SensorPairing')}>
<Text style={[styles.sensorBtnText, { color: accent }]}> Sensors</Text>
</TouchableOpacity>
</View>
<View style={styles.controls}>
<TouchableOpacity
style={[styles.awakeBtn, keepAwake && styles.awakeBtnOn]}
style={[styles.awakeBtn, keepAwake && { borderColor: accent }]}
onPress={() => setKeepAwake(!keepAwake)}
>
<Text style={styles.awakeBtnText}>{keepAwake ? '◑ Awake' : '◌ Sleep'}</Text>
@@ -149,11 +147,14 @@ export function RecordingScreen() {
);
}
function StatBox({ label, value }: { label: string; value: string }) {
function StatBox({ label, value, labelStyle, valueStyle }: {
label: string; value: string;
labelStyle: object; valueStyle: object;
}) {
return (
<View style={styles.statBox}>
<Text style={styles.statLabel}>{label}</Text>
<Text style={styles.statValue}>{value}</Text>
<Text style={labelStyle}>{label}</Text>
<Text style={valueStyle}>{value}</Text>
</View>
);
}
@@ -162,14 +163,11 @@ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
statBox: { width: '25%', padding: 8, alignItems: 'center' },
statLabel: { color: colors.textMuted, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 },
statValue: { color: colors.text, fontSize: 17, fontWeight: '600', marginTop: 2 },
mapArea: { flex: 1, overflow: 'hidden' },
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, borderColor: colors.border, paddingVertical: 6, paddingHorizontal: 12 },
sensorBtnText: { color: colors.accent, fontSize: 13, fontWeight: '600' },
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
sensorBtnText: { fontSize: 13, fontWeight: '600' },
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
awakeBtnOn: { borderColor: colors.accent },
awakeBtnText: { color: colors.textSub, fontSize: 13 },
btn: { paddingVertical: 15, paddingHorizontal: 30, borderRadius: 50 },
btnStart: { backgroundColor: colors.btnStart },
+6 -4
View File
@@ -7,8 +7,10 @@ import { uploadGpx } from '../services/upload';
import { SavedRecording } from '../types';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
export function SavedRecordingsScreen() {
const { accent } = useTheme();
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState<string | null>(null);
@@ -57,7 +59,7 @@ export function SavedRecordingsScreen() {
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
if (loading) return <View style={styles.center}><ActivityIndicator color={colors.accent} /></View>;
if (loading) return <View style={styles.center}><ActivityIndicator color={accent} /></View>;
return (
<View style={styles.container}>
@@ -75,10 +77,10 @@ export function SavedRecordingsScreen() {
</View>
<View style={styles.cardActions}>
<TouchableOpacity onPress={() => handleShare(item)}>
<Text style={styles.action}>Export</Text>
<Text style={[styles.action, { color: accent }]}>Export</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
<Text style={styles.action}>{uploading === item.id ? '…' : 'Upload'}</Text>
<Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
@@ -101,6 +103,6 @@ const styles = StyleSheet.create({
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
cardActions:{ flexDirection: 'row', gap: 20 },
action: { color: colors.accent, fontWeight: '600', fontSize: 14 },
action: { fontWeight: '600', fontSize: 14 },
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
});
+12 -10
View File
@@ -9,6 +9,7 @@ import {
} from '../services/ble';
import { BleDevice } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
const TYPE_LABEL: Record<BleDevice['type'], string> = {
hr: 'Heart Rate',
@@ -24,6 +25,7 @@ interface SavedEntry {
}
export function SensorPairingScreen() {
const { accent, accentDim } = useTheme();
const [scanning, setScanning] = useState(false);
const [found, setFound] = useState<BleDevice[]>([]);
const [saved, setSaved] = useState<SavedEntry[]>([]);
@@ -127,11 +129,11 @@ export function SensorPairingScreen() {
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
</View>
<View style={styles.deviceActions}>
{entry.status === 'connecting' && <ActivityIndicator color={colors.accent} />}
{entry.status === 'connecting' && <ActivityIndicator color={accent} />}
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
{(entry.status === 'saved' || entry.status === 'error') && (
<TouchableOpacity style={styles.reconnectBtn} onPress={() => reconnect(entry.device, true)}>
<Text style={styles.reconnectBtnText}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
<TouchableOpacity style={[styles.reconnectBtn, { backgroundColor: accentDim }]} onPress={() => reconnect(entry.device, true)}>
<Text style={[styles.reconnectBtnText, { color: accent }]}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={() => handleForget(entry.device)}>
@@ -150,13 +152,13 @@ export function SensorPairingScreen() {
<Text style={styles.deviceType}>{TYPE_LABEL[device.type]}</Text>
</View>
<TouchableOpacity
style={styles.connectBtn}
style={[styles.connectBtn, { backgroundColor: accentDim }]}
onPress={() => handleConnect(device)}
disabled={isConnecting}
>
{isConnecting
? <ActivityIndicator color={colors.text} />
: <Text style={styles.connectBtnText}>Connect</Text>}
? <ActivityIndicator color={accent} />
: <Text style={[styles.connectBtnText, { color: accent }]}>Connect</Text>}
</TouchableOpacity>
</View>
);
@@ -182,10 +184,10 @@ const styles = StyleSheet.create({
deviceType: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
connectedLabel: { color: colors.success, fontWeight: '600', fontSize: 13 },
reconnectBtn: { backgroundColor: colors.accentDim, borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
reconnectBtnText:{ color: colors.accent, fontWeight: '600', fontSize: 13 },
reconnectBtn: { borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
reconnectBtnText:{ fontWeight: '600', fontSize: 13 },
forgetLabel: { color: colors.error, fontSize: 13 },
connectBtn: { backgroundColor: colors.accentDim, borderRadius: 8, paddingVertical: 7, paddingHorizontal: 14, minWidth: 80, alignItems: 'center' },
connectBtnText: { color: colors.accent, fontWeight: '600' },
connectBtn: { borderRadius: 8, paddingVertical: 7, paddingHorizontal: 14, minWidth: 80, alignItems: 'center' },
connectBtnText: { fontWeight: '600' },
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 40 },
});
+171 -95
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 },
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: { color: colors.success, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
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 },
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 },
});
+16 -4
View File
@@ -9,16 +9,28 @@ export const colors = {
textMuted: '#71717a',
placeholder: '#52525b',
accent: '#60a5fa',
accentDim: 'rgba(96,165,250,0.15)',
success: '#86efac',
successBg: '#14532d',
error: '#fca5a5',
errorBg: '#7f1d1d',
// recording action buttons
btnStart: '#16a34a',
btnPause: '#d97706',
btnStop: '#dc2626',
} as const;
export type PaletteKey = 'default' | 'giro' | 'tour' | 'vuelta';
export type FontSizeKey = 'small' | 'medium' | 'large';
export const PALETTES: Record<PaletteKey, { accent: string; accentDim: string; label: string }> = {
default: { accent: '#60a5fa', accentDim: 'rgba(96,165,250,0.15)', label: 'Default' },
giro: { accent: '#f472b6', accentDim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
tour: { accent: '#facc15', accentDim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
vuelta: { accent: '#ef4444', accentDim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
};
export const FONT_SCALE: Record<FontSizeKey, number> = {
small: 0.88,
medium: 1.00,
large: 1.15,
};