dfe5307ab4
- theme.ts: useTheme() hook with race calendar (May–Sep windows), auto-detects Giro/Tour/Vuelta by date; stores override in SQLite - All screens (feed, import, activity, tab bar) now use accent/dim from useTheme() instead of hardcoded #60a5fa - Settings: Palette section with Auto/Default/Giro/Tour/Vuelta buttons to override the auto-detected palette for testing
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import * as DocumentPicker from 'expo-document-picker';
|
||
import * as FileSystem from 'expo-file-system/legacy';
|
||
import { useSQLiteContext } from 'expo-sqlite';
|
||
import { useState } from 'react';
|
||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { insertActivity } from '@/db/queries';
|
||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||
import { extractFile } from '@/extraction/extractActivity';
|
||
import { useTheme } from '@/theme';
|
||
|
||
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
|
||
const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz'];
|
||
const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS];
|
||
|
||
type ImportState =
|
||
| { status: 'idle' }
|
||
| { status: 'loading'; msg: string }
|
||
| { status: 'done'; title: string; id: string }
|
||
| { status: 'error'; message: string };
|
||
|
||
export default function ImportScreen() {
|
||
const db = useSQLiteContext();
|
||
const theme = useTheme();
|
||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||
|
||
async function pickFile() {
|
||
setState({ status: 'loading', msg: 'Picking file…' });
|
||
try {
|
||
const result = await DocumentPicker.getDocumentAsync({
|
||
type: ['*/*'],
|
||
copyToCacheDirectory: true,
|
||
});
|
||
if (result.canceled || !result.assets?.[0]) {
|
||
setState({ status: 'idle' });
|
||
return;
|
||
}
|
||
|
||
const asset = result.assets[0];
|
||
const name = asset.name ?? '';
|
||
const uri = asset.uri;
|
||
const lower = name.toLowerCase();
|
||
|
||
if (lower.endsWith('.json')) {
|
||
await importBasJson(uri, name, db);
|
||
} else if (ALL_NATIVE_EXTENSIONS.some(ext => lower.endsWith(ext))) {
|
||
await importNativeFile(uri, name, db);
|
||
} else {
|
||
setState({ status: 'error', message: `Unsupported file type: ${name}` });
|
||
}
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
setState({ status: 'error', message: msg });
|
||
}
|
||
}
|
||
|
||
// ── BAS JSON import (no extraction needed) ──────────────────────────────────
|
||
|
||
async function importBasJson(uri: string, name: string, dbCtx: typeof db) {
|
||
setState({ status: 'loading', msg: 'Importing…' });
|
||
const text = await FileSystem.readAsStringAsync(uri);
|
||
const detail = JSON.parse(text);
|
||
|
||
if (!detail.id || !detail.started_at) {
|
||
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
|
||
}
|
||
|
||
const hash = detail.source_hash ?? `${detail.id}-${text.length}`;
|
||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||
const dest = `${origDir}${detail.id}.json`;
|
||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||
|
||
await insertActivity(dbCtx, {
|
||
id: detail.id,
|
||
source_hash: hash,
|
||
detail_json: text,
|
||
timeseries_json: null,
|
||
geojson: null,
|
||
original_path: dest,
|
||
origin: 'local',
|
||
});
|
||
|
||
setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id });
|
||
}
|
||
|
||
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
|
||
|
||
async function importNativeFile(uri: string, name: string, dbCtx: typeof db) {
|
||
setState({ status: 'loading', msg: 'Reading file…' });
|
||
|
||
// Read the original file as base64 so we can (a) pass it to the WebView
|
||
// and (b) copy it to permanent storage without a second read.
|
||
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
});
|
||
|
||
// Fetch the bincio wheel here (React Native networking), not inside the
|
||
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
|
||
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
|
||
const instanceUrl = await getInstanceUrl(dbCtx);
|
||
setState({ status: 'loading', msg: 'Fetching Bincio engine…' });
|
||
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
|
||
|
||
const result = await extractFile(
|
||
name,
|
||
base64,
|
||
wheelBase64,
|
||
wheelFilename,
|
||
(msg) => setState({ status: 'loading', msg }),
|
||
);
|
||
|
||
setState({ status: 'loading', msg: 'Saving…' });
|
||
|
||
// Copy original file to permanent storage (keeps original bytes for future re-extraction)
|
||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||
const dest = `${origDir}${result.id}${ext}`;
|
||
await FileSystem.copyAsync({ from: uri, to: dest });
|
||
|
||
await insertActivity(dbCtx, {
|
||
id: result.id,
|
||
source_hash: result.sourceHash,
|
||
detail_json: JSON.stringify(result.detail),
|
||
timeseries_json: result.timeseries ? JSON.stringify(result.timeseries) : null,
|
||
geojson: result.geojson ? JSON.stringify(result.geojson) : null,
|
||
original_path: dest,
|
||
origin: 'local',
|
||
});
|
||
|
||
const d = result.detail as Record<string, unknown>;
|
||
setState({ status: 'done', title: (d.title as string) ?? result.id, id: result.id });
|
||
}
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
{/* Hidden WebView for Pyodide — mounted here so it lives inside the tab
|
||
(Expo Router keeps tabs mounted after first visit, preserving Pyodide state).
|
||
The 1×1 container clips it out of the scroll layout entirely. */}
|
||
<View style={styles.hiddenEngine}>
|
||
<PyodideWebView />
|
||
</View>
|
||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||
<Text style={styles.header}>Import</Text>
|
||
|
||
<Text style={styles.body}>
|
||
Import a FIT, GPX, or TCX file — extracted on your device, nothing uploaded.
|
||
You can also import a pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> file directly.
|
||
</Text>
|
||
|
||
<Pressable
|
||
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
||
onPress={state.status !== 'loading' ? pickFile : undefined}
|
||
>
|
||
<Text style={styles.buttonText}>
|
||
{state.status === 'loading' ? 'Working…' : '+ Pick file'}
|
||
</Text>
|
||
</Pressable>
|
||
|
||
{state.status === 'loading' && (
|
||
<View style={styles.statusBox}>
|
||
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
|
||
<Text style={styles.statusHint}>
|
||
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{state.status === 'done' && (
|
||
<View style={styles.success}>
|
||
<Text style={styles.successText}>✓ Imported: {state.title}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{state.status === 'error' && (
|
||
<View style={styles.error}>
|
||
<Text style={styles.errorText}>{state.message}</Text>
|
||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||
<Text style={styles.errorRetry}>Try another file</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.divider} />
|
||
|
||
<Text style={styles.sectionTitle}>Supported formats</Text>
|
||
{([
|
||
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
||
['GPX', 'Most GPS devices and apps'],
|
||
['TCX', 'Garmin Training Center'],
|
||
['BAS JSON', 'Pre-extracted Bincio format (instant)'],
|
||
] as [string, string][]).map(([fmt, desc]) => (
|
||
<View key={fmt} style={styles.formatRow}>
|
||
<Text style={styles.formatName}>{fmt}</Text>
|
||
<Text style={styles.formatDesc}>{desc}</Text>
|
||
</View>
|
||
))}
|
||
|
||
<View style={styles.notice}>
|
||
<Text style={styles.noticeText}>
|
||
FIT/GPX/TCX extraction runs entirely on your device.
|
||
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).
|
||
</Text>
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
async function getInstanceUrl(db: ReturnType<typeof useSQLiteContext>): Promise<string> {
|
||
const row = db.getFirstSync<{ value: string }>(
|
||
'SELECT value FROM settings WHERE key = ?',
|
||
['instance_url'],
|
||
);
|
||
return (row?.value ?? '').replace(/\/$/, '');
|
||
}
|
||
|
||
// In-memory cache so repeated imports in one session don't re-download the wheel.
|
||
let _cachedWheel: { base64: string; filename: string } | null = null;
|
||
|
||
async function fetchWheelBase64(instanceUrl: string): Promise<{ base64: string; filename: string }> {
|
||
if (_cachedWheel) return _cachedWheel;
|
||
|
||
const base = instanceUrl || 'https://bincio.org';
|
||
|
||
// Ask the instance for the canonical wheel URL (handles both dev and prod layouts).
|
||
let wheelUrl = `${base}/api/wheel/download`;
|
||
let wheelFilename = 'bincio-0.1.0-py3-none-any.whl';
|
||
try {
|
||
const vr = await fetch(`${base}/api/wheel/version`, { signal: AbortSignal.timeout(5000) });
|
||
if (vr.ok) {
|
||
const d = await vr.json() as { api_url?: string; url?: string };
|
||
const path = d.api_url ?? d.url ?? '/api/wheel/download';
|
||
wheelUrl = path.startsWith('http') ? path : `${base}${path}`;
|
||
// Extract the filename from the URL path (last segment after final /)
|
||
const urlBasename = wheelUrl.split('/').pop() ?? '';
|
||
if (urlBasename.endsWith('.whl')) wheelFilename = urlBasename;
|
||
}
|
||
} catch {}
|
||
|
||
// Fetch via React Native networking (supports local HTTP; WKWebView would block it).
|
||
const resp = await fetch(wheelUrl);
|
||
if (!resp.ok) throw new Error(`Could not download Bincio engine (${resp.status}). Is the instance running?`);
|
||
const buf = await resp.arrayBuffer();
|
||
_cachedWheel = { base64: arrayBufferToBase64(buf), filename: wheelFilename };
|
||
return _cachedWheel;
|
||
}
|
||
|
||
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
||
const bytes = new Uint8Array(buf);
|
||
let binary = '';
|
||
// Process in chunks to avoid spread-operator stack overflow on large arrays.
|
||
const CHUNK = 8192;
|
||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||
binary += String.fromCharCode(...(bytes.subarray(i, i + CHUNK) as unknown as number[]));
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
// ── Styles ───────────────────────────────────────────────────────────────────
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: { flex: 1, backgroundColor: '#09090b' },
|
||
hiddenEngine: { position: 'absolute', width: 1, height: 1, overflow: 'hidden' },
|
||
container: { flex: 1 },
|
||
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||
button: {
|
||
backgroundColor: '#2563eb', borderRadius: 10,
|
||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||
},
|
||
buttonDisabled: { opacity: 0.5 },
|
||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||
statusBox: {
|
||
backgroundColor: '#18181b', borderRadius: 8, borderWidth: 1,
|
||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 6,
|
||
},
|
||
statusMsg: { color: '#60a5fa', fontSize: 14, textAlign: 'center' },
|
||
statusHint: { color: '#52525b', fontSize: 12, textAlign: 'center', lineHeight: 16 },
|
||
success: {
|
||
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16,
|
||
},
|
||
successText: { color: '#86efac', fontSize: 14 },
|
||
error: {
|
||
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
||
},
|
||
errorText: { color: '#fca5a5', fontSize: 14 },
|
||
errorRetry: { color: '#71717a', fontSize: 13, textDecorationLine: 'underline' },
|
||
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
||
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
||
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
||
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
||
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
||
notice: {
|
||
marginTop: 8, backgroundColor: '#18181b',
|
||
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
||
},
|
||
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
|
||
});
|