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 { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { insertActivity } from '@/db/queries'; type ImportState = | { status: 'idle' } | { status: 'loading' } | { status: 'done'; title: string; id: string } | { status: 'error'; message: string }; export default function ImportScreen() { const db = useSQLiteContext(); const [state, setState] = useState({ status: 'idle' }); async function pickFile() { setState({ status: 'loading' }); 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, db); const detail = JSON.parse(await FileSystem.readAsStringAsync(uri)); setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id }); } else if (['.fit', '.gpx', '.tcx', '.fit.gz', '.gpx.gz', '.tcx.gz'].some(ext => lower.endsWith(ext))) { // Phase 1: Pyodide extraction. Placeholder for now. Alert.alert( 'Extraction coming in Phase 1', `File "${name}" received. FIT/GPX/TCX extraction via Pyodide will be added in Phase 1. For now, you can import a pre-extracted BAS .json file.`, ); setState({ status: 'idle' }); } else { setState({ status: 'error', message: `Unsupported file type: ${name}` }); } } catch (e: unknown) { setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); } } async function importBasJson(uri: string, dbCtx: typeof db) { 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)'); } // Simple hash: SHA-256 not available without a library, use content length + id as stand-in. // Phase 1 will use a proper hash. const hash = `${detail.id}-${text.length}`; // Copy to permanent storage 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', }); } return ( Import Import a FIT, GPX, or TCX file to extract and store it locally. You can also import a pre-extracted BAS .json file directly. {state.status === 'loading' ? 'Importing…' : '+ Pick file'} {state.status === 'done' && ( ✓ Imported: {state.title} )} {state.status === 'error' && ( {state.message} )} Supported formats {[ ['FIT', 'Garmin, Wahoo, Karoo native format'], ['GPX', 'Most GPS devices and apps'], ['TCX', 'Garmin Training Center'], ['BAS JSON', 'Pre-extracted Bincio format'], ].map(([fmt, desc]) => ( {fmt} {desc} ))} FIT/GPX/TCX extraction runs entirely on your device via the Bincio extraction engine. No data is uploaded. ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#09090b' }, 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 }, success: { backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, }, successText: { color: '#86efac', fontSize: 14 }, error: { backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, }, errorText: { color: '#fca5a5', fontSize: 14 }, 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 }, });