Files
bincio-autarchive/app/(tabs)/settings.tsx
T
2026-06-03 16:21:28 +02:00

315 lines
13 KiB
TypeScript

import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react';
import {
ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View,
} from 'react-native';
import { deleteRemoteActivities, setSetting, useSetting } from '@/db/queries';
import { PALETTES, type PaletteKey } from '@/theme';
import { useTheme, usePaletteControl } from '@/ThemeContext';
import { login, logout } from '@/services/auth';
export default function SettingsScreen() {
const db = useSQLiteContext();
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full';
const storedSyncUpload = useSetting('sync_upload') === 'true';
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
const [autoPath, setAutoPath] = useState(storedPath);
const [syncMode, setSyncMode] = useState(storedSyncMode);
const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
const theme = useTheme();
const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
const [connecting, setConnecting] = useState(false);
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [resetArmed, setResetArmed] = useState(false);
const [resetMsg, setResetMsg] = useState<string | null>(null);
async function handleConnect() {
setConnecting(true);
setConnectMsg(null);
const result = await login(db);
setConnecting(false);
if (result.ok) {
setConnectMsg({ ok: true, text: `Connected as ${result.displayName ?? ''}` });
} else if (result.error !== 'Cancelled') {
setConnectMsg({ ok: false, text: result.error ?? 'Sign in failed' });
}
}
async function handleDisconnect() {
Alert.alert('Disconnect', 'Remove saved credentials?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
await logout(db);
setConnectMsg(null);
}},
]);
}
async function resetSyncedData() {
if (!resetArmed) {
setResetArmed(true);
return;
}
const n = await deleteRemoteActivities(db);
setResetArmed(false);
setResetMsg(`Removed ${n} synced ${n === 1 ? 'activity' : 'activities'}`);
setTimeout(() => setResetMsg(null), 3000);
}
const isConnected = !!storedToken;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text>
<Section title="Connection">
{isConnected ? (
<>
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
<Pressable style={styles.disconnectButton} onPress={handleDisconnect}>
<Text style={styles.disconnectText}>Disconnect</Text>
</Pressable>
</>
) : (
<Pressable
style={[styles.connectButton, connecting && styles.buttonDisabled]}
onPress={connecting ? undefined : handleConnect}
>
{connecting
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.connectText}>Sign in with bincio</Text>}
</Pressable>
)}
{connectMsg && (
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
{connectMsg.text}
</Text>
)}
</Section>
{Platform.OS === 'android' && (
<Section title="Auto-import (Android)">
{!isConnected ? (
<Text style={[styles.hint, styles.hintWarn]}>
Sign in to a Bincio instance above first it's needed to download the extraction engine.
</Text>
) : (
<>
<Field
label="Watch directory"
placeholder="/sdcard/FitFiles"
value={autoPath}
onChangeText={setAutoPath}
onBlur={() => setSetting(db, 'auto_import_path', autoPath.trim())}
autoCapitalize="none"
/>
<Text style={styles.hint}>
New FIT files in this folder are imported automatically when you
open the app. Leave blank to disable. Requires storage permission.
</Text>
</>
)}
</Section>
)}
<Section title="Sync">
<Text style={styles.subLabel}>Download</Text>
<View style={styles.modeRow}>
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
</View>
<Text style={styles.hint}>
{syncMode === 'full'
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
<View style={styles.modeRow}>
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
</View>
<Text style={styles.hint}>
{syncUpload
? 'Local activities are uploaded to the instance during sync.'
: 'Local activities stay on device only.'}
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload format</Text>
<View style={styles.modeRow}>
<ModeButton label="Original file" active={uploadFormat === 'raw'} accent={theme.accent} dim={theme.dim}
onPress={() => { setUploadFormat('raw'); setSetting(db, 'upload_format', 'raw'); }} />
<ModeButton label="Extracted JSON" active={uploadFormat === 'bas'} accent={theme.accent} dim={theme.dim}
onPress={() => { setUploadFormat('bas'); setSetting(db, 'upload_format', 'bas'); }} />
</View>
<Text style={styles.hint}>
{uploadFormat === 'raw'
? 'Uploads the original FIT/GPX/TCX file. The server re-extracts it with DEM elevation correction and updates your local copy.'
: 'Uploads the pre-extracted JSON. Faster, but no DEM elevation correction.'}
</Text>
</Section>
<Section title="Palette">
<Text style={[styles.hint, { paddingBottom: 0 }]}>
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
</Text>
<View style={styles.modeRow}>
{(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => {
const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label;
const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent;
const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim;
return (
<ModeButton
key={key}
label={label}
active={palette === key}
accent={keyAccent}
dim={keyDim}
onPress={() => setPaletteOverride(key)}
/>
);
})}
</View>
</Section>
<Section title="Data">
<Pressable
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
onPress={resetSyncedData}
onBlur={() => setResetArmed(false)}
>
<Text style={[styles.resetText, resetArmed && styles.resetTextArmed]}>
{resetArmed ? 'Tap again to confirm' : 'Reset synced data'}
</Text>
</Pressable>
{resetMsg && <Text style={styles.msgOk}>{resetMsg}</Text>}
<Text style={styles.hint}>
Removes all activities synced from the instance. Locally imported files are kept.
</Text>
</Section>
<Section title="About">
<Row label="Version" value="0.1.0 (Phase 0.5)" />
<Row label="Schema" value="BAS 1.0" />
</Section>
</ScrollView>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.sectionBody}>{children}</View>
</View>
);
}
function Field({
label, placeholder, value, onChangeText, ...rest
}: {
label: string;
placeholder: string;
value: string;
onChangeText: (v: string) => void;
[key: string]: unknown;
}) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor="#52525b"
value={value}
onChangeText={onChangeText}
{...rest}
/>
</View>
);
}
function ModeButton({ label, active, accent, dim, onPress }: {
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
}) {
return (
<Pressable
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
onPress={onPress}
>
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
</Pressable>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<View style={styles.row}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
section: { marginBottom: 28 },
sectionTitle: {
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
letterSpacing: 0.8, marginBottom: 8,
},
sectionBody: {
backgroundColor: '#18181b', borderRadius: 10,
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
},
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
input: { color: '#f4f4f5', fontSize: 15 },
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
hintWarn: { color: '#a16207' },
row: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: 1, borderBottomColor: '#27272a',
},
rowLabel: { color: '#a1a1aa', fontSize: 14 },
rowValue: { color: '#71717a', fontSize: 14 },
connectButton: {
backgroundColor: '#059669', borderRadius: 8, margin: 12,
paddingVertical: 12, alignItems: 'center',
},
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
buttonDisabled: { opacity: 0.5 },
disconnectButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 },
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' },
resetButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
resetButtonArmed: { borderColor: '#ef4444', backgroundColor: '#1c0a0a' },
resetText: { color: '#71717a', fontSize: 14 },
resetTextArmed: { color: '#ef4444', fontWeight: '600' },
});