import { useSQLiteContext } from 'expo-sqlite'; import { useState } from 'react'; import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries'; import { PALETTES, type PaletteKey, useTheme } from '@/theme'; export default function SettingsScreen() { const db = useSQLiteContext(); const storedUrl = useSetting('instance_url') ?? ''; 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 [instanceUrl, setInstanceUrl] = useState(storedUrl); const [handle, setHandle] = useState(storedHandle); const [autoPath, setAutoPath] = useState(storedPath); const [syncMode, setSyncMode] = useState(storedSyncMode); const [syncUpload, setSyncUpload] = useState(storedSyncUpload); const [saved, setSaved] = useState(false); const theme = useTheme(); const storedPalette = (useSetting('palette_override') ?? 'auto') as PaletteKey; const [palette, setPalette] = useState(storedPalette); const [password, setPassword] = useState(''); const [connecting, setConnecting] = useState(false); const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null); const [resetArmed, setResetArmed] = useState(false); const [resetMsg, setResetMsg] = useState(null); async function save() { await setSetting(db, 'instance_url', instanceUrl.trim()); await setSetting(db, 'handle', handle.trim()); if (Platform.OS === 'android') { await setSetting(db, 'auto_import_path', autoPath.trim()); } setSaved(true); setTimeout(() => setSaved(false), 2000); } async function connect() { const url = instanceUrl.trim().replace(/\/$/, ''); const h = handle.trim(); if (!url || !h || !password) { setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' }); return; } setConnecting(true); setConnectMsg(null); try { const resp = await fetch(`${url}/api/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: h, password }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` }); return; } const data = await resp.json(); await setSetting(db, 'instance_url', url); await setSetting(db, 'handle', h); await setSetting(db, 'api_token', data.token); setPassword(''); setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` }); } catch { setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' }); } finally { setConnecting(false); } } async function disconnect() { await setSetting(db, 'api_token', ''); 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 ( Settings
Connect to a Bincio instance to sync your activities. Leave blank to use the app offline only.
{saved ? '✓ Saved' : 'Save'}
{isConnected ? ( <> Disconnect ) : ( <> {connecting ? : Connect} )} {connectMsg && ( {connectMsg.text} )} Your password is used once to obtain a session token, then forgotten. The token is stored locally and sent with each sync request.
{Platform.OS === 'android' && (
New FIT files in this directory are imported automatically in the background. Leave blank to disable. Requires storage permission.
)}
Download { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} /> { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} /> {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.'} Upload { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} /> { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} /> {syncUpload ? 'Local activities are uploaded to the instance during sync.' : 'Local activities stay on device only.'}
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing. {(['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 ( { setPalette(key); setSetting(db, 'palette_override', key); }} /> ); })}
setResetArmed(false)} > {resetArmed ? 'Tap again to confirm' : 'Reset synced data'} {resetMsg && {resetMsg}} Removes all activities synced from the instance. Locally imported files are kept.
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( {title} {children} ); } function Field({ label, placeholder, value, onChangeText, ...rest }: { label: string; placeholder: string; value: string; onChangeText: (v: string) => void; [key: string]: unknown; }) { return ( {label} ); } function ModeButton({ label, active, accent, dim, onPress }: { label: string; active: boolean; accent: string; dim: string; onPress: () => void; }) { return ( {label} ); } function Row({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } 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 }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#27272a', }, rowLabel: { color: '#a1a1aa', fontSize: 14 }, rowValue: { color: '#71717a', fontSize: 14 }, saveButton: { backgroundColor: '#2563eb', borderRadius: 10, paddingVertical: 14, alignItems: 'center', marginBottom: 28, }, saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 }, 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' }, });