feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user