2e520137ac
Replace useSetting()-based useTheme() with a React context (ThemeProvider + useTheme/usePaletteControl). The context holds palette key in state so pressing a palette button in Settings re-renders all screens instantly. Persists to SQLite and reloads the stored value on mount.
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
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 } from '@/theme';
|
|
import { useTheme, usePaletteControl } from '@/ThemeContext';
|
|
|
|
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 { paletteKey: palette, setPaletteOverride } = usePaletteControl();
|
|
|
|
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<string | null>(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 (
|
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
<Text style={styles.header}>Settings</Text>
|
|
|
|
<Section title="Instance">
|
|
<Field
|
|
label="Instance URL"
|
|
placeholder="https://bincio.org"
|
|
value={instanceUrl}
|
|
onChangeText={setInstanceUrl}
|
|
autoCapitalize="none"
|
|
keyboardType="url"
|
|
/>
|
|
<Field
|
|
label="Handle"
|
|
placeholder="yourhandle"
|
|
value={handle}
|
|
onChangeText={setHandle}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Text style={styles.hint}>
|
|
Connect to a Bincio instance to sync your activities. Leave blank to use
|
|
the app offline only.
|
|
</Text>
|
|
</Section>
|
|
|
|
<Pressable style={styles.saveButton} onPress={save}>
|
|
<Text style={styles.saveButtonText}>
|
|
{saved ? '✓ Saved' : 'Save'}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
<Section title="Connection">
|
|
{isConnected ? (
|
|
<>
|
|
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
|
|
<Pressable style={styles.disconnectButton} onPress={disconnect}>
|
|
<Text style={styles.disconnectText}>Disconnect</Text>
|
|
</Pressable>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Field
|
|
label="Password"
|
|
placeholder="••••••••"
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
autoCapitalize="none"
|
|
secureTextEntry
|
|
/>
|
|
<Pressable
|
|
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
|
onPress={connecting ? undefined : connect}
|
|
>
|
|
{connecting
|
|
? <ActivityIndicator color="#fff" size="small" />
|
|
: <Text style={styles.connectText}>Connect</Text>}
|
|
</Pressable>
|
|
</>
|
|
)}
|
|
{connectMsg && (
|
|
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
|
|
{connectMsg.text}
|
|
</Text>
|
|
)}
|
|
<Text style={styles.hint}>
|
|
Your password is used once to obtain a session token, then forgotten.
|
|
The token is stored locally and sent with each sync request.
|
|
</Text>
|
|
</Section>
|
|
|
|
{Platform.OS === 'android' && (
|
|
<Section title="Auto-import (Android)">
|
|
<Field
|
|
label="Watch directory"
|
|
placeholder="/sdcard/Karoo/Rides"
|
|
value={autoPath}
|
|
onChangeText={setAutoPath}
|
|
autoCapitalize="none"
|
|
/>
|
|
<Text style={styles.hint}>
|
|
New FIT files in this directory are imported automatically in the
|
|
background. 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>
|
|
</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 },
|
|
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' },
|
|
});
|