Files
bincio-activity/mobile/app/(tabs)/import.tsx
T

171 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system';
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<ImportState>({ 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 (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Import</Text>
<Text style={styles.body}>
Import a FIT, GPX, or TCX file to extract and store it locally.
You can also import a pre-extracted BAS <Text style={styles.code}>.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' ? 'Importing…' : ' Pick file'}
</Text>
</Pressable>
{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>
</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'],
].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 via the Bincio
extraction engine. No data is uploaded.
</Text>
</View>
</ScrollView>
);
}
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 },
});