feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens

This commit is contained in:
Davide Scaini
2026-04-24 10:39:06 +02:00
parent 565f5a3ff1
commit b37df88fe1
17 changed files with 1076 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
tabBarActiveTintColor: '#60a5fa',
tabBarInactiveTintColor: '#71717a',
}}
>
<Tabs.Screen
name="index"
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
/>
<Tabs.Screen
name="import"
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
/>
<Tabs.Screen
name="settings"
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
/>
</Tabs>
);
}
function TabIcon({ label, color }: { label: string; color: string }) {
const { Text } = require('react-native');
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
}
+170
View File
@@ -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 },
});
+115
View File
@@ -0,0 +1,115 @@
import { useRouter } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useActivities, type ActivitySummary } from '@/db/queries';
export default function FeedScreen() {
const activities = useActivities();
if (activities.length === 0) {
return (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}>
Go to Import to add a FIT, GPX, or TCX file.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.header}>Feed</Text>
<FlatList
data={activities}
keyExtractor={(a) => a.id}
renderItem={({ item }) => <ActivityCard activity={item} />}
contentContainerStyle={styles.list}
/>
</View>
);
}
function ActivityCard({ activity }: { activity: ActivitySummary }) {
const router = useRouter();
const km = activity.distance_m != null
? (activity.distance_m / 1000).toFixed(1)
: null;
const elev = activity.elevation_gain_m != null
? Math.round(activity.elevation_gain_m)
: null;
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
return (
<Pressable
style={styles.card}
onPress={() => router.push(`/activity/${activity.id}`)}
>
<View style={styles.cardTop}>
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
<View style={styles.cardMeta}>
<Text style={styles.cardDate}>{date}</Text>
{!activity.synced_at && activity.origin === 'local' && (
<Text style={styles.unsyncedBadge}>local</Text>
)}
</View>
</View>
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
<View style={styles.cardStats}>
{km && <Stat label="km" value={km} />}
{elev != null && <Stat label="m↑" value={String(elev)} />}
</View>
</Pressable>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<View style={styles.stat}>
<Text style={styles.statValue}>{value}</Text>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
function sportIcon(sport: string): string {
const icons: Record<string, string> = {
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
};
return icons[sport] ?? '🏅';
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
header: {
color: '#fff', fontSize: 22, fontWeight: '700',
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
list: { padding: 16, gap: 12 },
card: {
backgroundColor: '#18181b', borderRadius: 12,
padding: 16, borderWidth: 1, borderColor: '#27272a',
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
sportIcon: { fontSize: 20 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
cardDate: { color: '#71717a', fontSize: 12 },
unsyncedBadge: {
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
},
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
cardStats: { flexDirection: 'row', gap: 16 },
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
statLabel: { color: '#71717a', fontSize: 12 },
empty: {
flex: 1, backgroundColor: '#09090b',
alignItems: 'center', justifyContent: 'center', padding: 32,
},
emptyIcon: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
});
+159
View File
@@ -0,0 +1,159 @@
import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react';
import {
Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View,
} from 'react-native';
import { getSetting, setSetting, useSetting } from '@/db/queries';
export default function SettingsScreen() {
const db = useSQLiteContext();
const storedUrl = useSetting('instance_url') ?? '';
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath);
const [saved, setSaved] = useState(false);
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);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text>
<Section title="Instance (optional)">
<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}>
Leave blank to use the app without a remote instance. When set, you can
push activities to the instance and pull the web feed.
</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>
)}
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="About">
<Row label="Version" value="0.1.0 (Phase 0)" />
<Row label="Schema" value="BAS 1.0" />
<Row label="Extraction" value="Pyodide (Phase 1)" />
</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 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 },
});