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
+14
View File
@@ -437,6 +437,20 @@ async def stats() -> JSONResponse:
}) })
@app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks."""
import importlib.metadata
try:
version = importlib.metadata.version("bincio")
except importlib.metadata.PackageNotFoundError:
version = "0.1.0"
return JSONResponse({
"version": version,
"url": f"/bincio-{version}-py3-none-any.whl",
})
@app.post("/api/auth/login", response_model=LoginResponse) @app.post("/api/auth/login", response_model=LoginResponse)
async def login( async def login(
login_req: LoginRequest, login_req: LoginRequest,
+45
View File
@@ -51,6 +51,51 @@ server uses. Any tool in any language can read them.
--- ---
## Setup
### Prerequisites
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 18 | 20 LTS recommended — install via [nodejs.org](https://nodejs.org) or `nvm` |
| npm | ships with Node | |
| Expo Go app | latest | Install on your phone — scan the QR code to run the app instantly during development |
| Xcode | 15+ | **macOS only, iOS builds.** Install from the App Store, then `xcode-select --install` |
| Android Studio | latest | **Android builds / emulator.** Includes the SDK and `adb` |
You do **not** need Xcode or Android Studio to start. Expo Go lets you run the app
on your physical device by scanning a QR code — no native build required.
### First-time setup
```bash
# From the repo root:
bash mobile/setup.sh
```
The script checks prerequisites, installs npm dependencies, and generates the
required Expo type declarations. It prints next steps when done.
### Running the app
```bash
cd mobile
# Development server — scan QR with Expo Go on your phone
npx expo start
# Run on a connected Android device or emulator
npx expo run:android
# Run on iOS simulator (macOS only)
npx expo run:ios
# Build a standalone APK for Karoo sideloading
npx eas build -p android --profile preview
```
---
## Repository layout ## Repository layout
The mobile app lives in `mobile/` inside the main bincio repository (Option A). The mobile app lives in `mobile/` inside the main bincio repository (Option A).
+18
View File
@@ -0,0 +1,18 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Generated native projects (managed workflow — produced by EAS, not committed)
android/
ios/
# Local env overrides
.env.local
+36
View File
@@ -0,0 +1,36 @@
{
"expo": {
"name": "Bincio",
"slug": "bincio",
"version": "0.1.0",
"orientation": "portrait",
"scheme": "bincio",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"platforms": ["ios", "android"],
"android": {
"package": "org.bincio.app",
"permissions": [
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.POST_NOTIFICATIONS"
]
},
"ios": {
"bundleIdentifier": "org.bincio.app",
"supportsTablet": true
},
"plugins": [
"expo-router",
"expo-sqlite",
[
"expo-document-picker",
{ "iCloudContainerEnvironment": "Production" }
],
"expo-background-fetch",
"expo-task-manager"
]
}
}
+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 },
});
+13
View File
@@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
import { SQLiteProvider } from 'expo-sqlite';
import { StatusBar } from 'expo-status-bar';
import { migrateDb } from '@/db';
export default function RootLayout() {
return (
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
</SQLiteProvider>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useActivity } from '@/db/queries';
export default function ActivityScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const row = useActivity(id);
if (!row) {
return (
<View style={styles.center}>
<Text style={styles.notFound}>Activity not found</Text>
</View>
);
}
const detail = JSON.parse(row.detail_json);
const km = detail.distance_m != null
? (detail.distance_m / 1000).toFixed(2)
: null;
const elev = detail.elevation_gain_m != null
? Math.round(detail.elevation_gain_m)
: null;
const elevLoss = detail.elevation_loss_m != null
? Math.round(Math.abs(detail.elevation_loss_m))
: null;
const movingTime = detail.moving_time_s != null
? formatDuration(detail.moving_time_s)
: null;
const speed = detail.avg_speed_kmh != null
? detail.avg_speed_kmh.toFixed(1)
: null;
const hr = detail.avg_hr_bpm != null
? Math.round(detail.avg_hr_bpm)
: null;
const power = detail.avg_power_w != null
? Math.round(detail.avg_power_w)
: null;
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backText}> Back</Text>
</Pressable>
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
<Text style={styles.title}>{detail.title}</Text>
<Text style={styles.date}>{date}</Text>
{/* Map placeholder — Phase 1 */}
<View style={styles.mapPlaceholder}>
<Text style={styles.mapPlaceholderText}>Map · Phase 1</Text>
</View>
{/* Stats grid */}
<View style={styles.grid}>
{km && <StatCell label="Distance" value={km} unit="km" />}
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
{elev != null && <StatCell label="Elevation gain" value={String(elev)} unit="m" />}
{elevLoss != null && <StatCell label="Elevation loss" value={String(elevLoss)} unit="m" />}
{speed && <StatCell label="Avg speed" value={speed} unit="km/h" />}
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
</View>
{/* Elevation chart placeholder — Phase 1 */}
{row.timeseries_json && (
<View style={styles.chartPlaceholder}>
<Text style={styles.mapPlaceholderText}>Elevation chart · Phase 1</Text>
</View>
)}
<View style={styles.meta}>
<MetaRow label="Source" value={detail.source ?? '—'} />
<MetaRow label="Device" value={detail.device ?? '—'} />
<MetaRow label="Origin" value={row.origin} />
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
</View>
</ScrollView>
);
}
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<View style={styles.statCell}>
<View style={styles.statValueRow}>
<Text style={styles.statValue}>{value}</Text>
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
</View>
<Text style={styles.statLabel}>{label}</Text>
</View>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.metaRow}>
<Text style={styles.metaLabel}>{label}</Text>
<Text style={styles.metaValue}>{value}</Text>
</View>
);
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' },
content: { paddingBottom: 40 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
notFound: { color: '#71717a', fontSize: 16 },
backButton: { paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12 },
backText: { color: '#60a5fa', fontSize: 15 },
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
mapPlaceholder: {
height: 200, backgroundColor: '#18181b',
alignItems: 'center', justifyContent: 'center',
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
marginBottom: 16,
},
chartPlaceholder: {
height: 120, backgroundColor: '#18181b',
alignItems: 'center', justifyContent: 'center',
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
marginBottom: 16,
},
mapPlaceholderText: { color: '#3f3f46', fontSize: 13 },
grid: {
flexDirection: 'row', flexWrap: 'wrap',
paddingHorizontal: 12, gap: 8, marginBottom: 16,
},
statCell: {
backgroundColor: '#18181b', borderRadius: 10,
borderWidth: 1, borderColor: '#27272a',
padding: 14, width: '47%',
},
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
statUnit: { color: '#71717a', fontSize: 13 },
statLabel: { color: '#71717a', fontSize: 12 },
meta: {
marginHorizontal: 16, backgroundColor: '#18181b',
borderRadius: 10, borderWidth: 1, borderColor: '#27272a',
},
metaRow: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 10,
borderBottomWidth: 1, borderBottomColor: '#27272a',
},
metaLabel: { color: '#71717a', fontSize: 13 },
metaValue: { color: '#a1a1aa', fontSize: 13 },
});
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+26
View File
@@ -0,0 +1,26 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
await db.execAsync('PRAGMA journal_mode = WAL;');
await db.execAsync(`
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
source_hash TEXT NOT NULL,
detail_json TEXT NOT NULL,
timeseries_json TEXT,
geojson TEXT,
original_path TEXT,
synced_at INTEGER,
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_activities_created_at
ON activities(created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
}
+120
View File
@@ -0,0 +1,120 @@
import { useSQLiteContext } from 'expo-sqlite';
// ── Types ──────────────────────────────────────────────────────────────────
export type ActivityRow = {
id: string;
source_hash: string;
detail_json: string;
timeseries_json: string | null;
geojson: string | null;
original_path: string | null;
synced_at: number | null;
origin: 'local' | 'remote';
created_at: number;
};
export type ActivitySummary = {
id: string;
title: string;
sport: string;
started_at: string;
distance_m: number | null;
duration_s: number | null;
elevation_gain_m: number | null;
origin: 'local' | 'remote';
synced_at: number | null;
};
// ── Activities ─────────────────────────────────────────────────────────────
export function useActivities(): ActivitySummary[] {
const db = useSQLiteContext();
// Summaries are derived from the stored detail_json at query time.
// JSON extraction via SQLite's json_extract keeps the table schema simple.
const rows = db.getAllSync<{
id: string;
origin: 'local' | 'remote';
synced_at: number | null;
title: string;
sport: string;
started_at: string;
distance_m: number | null;
duration_s: number | null;
elevation_gain_m: number | null;
}>(`
SELECT
id, origin, synced_at,
json_extract(detail_json, '$.title') AS title,
json_extract(detail_json, '$.sport') AS sport,
json_extract(detail_json, '$.started_at') AS started_at,
json_extract(detail_json, '$.distance_m') AS distance_m,
json_extract(detail_json, '$.duration_s') AS duration_s,
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
FROM activities
ORDER BY created_at DESC
`);
return rows;
}
export function useActivity(id: string): ActivityRow | null {
const db = useSQLiteContext();
return db.getFirstSync<ActivityRow>(
'SELECT * FROM activities WHERE id = ?',
[id],
) ?? null;
}
export async function insertActivity(
db: ReturnType<typeof useSQLiteContext>,
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>,
): Promise<void> {
await db.runAsync(
`INSERT OR IGNORE INTO activities
(id, source_hash, detail_json, timeseries_json, geojson, original_path, origin)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
row.source_hash,
row.detail_json,
row.timeseries_json ?? null,
row.geojson ?? null,
row.original_path ?? null,
row.origin,
],
);
}
// ── Settings ───────────────────────────────────────────────────────────────
export async function getSetting(
db: ReturnType<typeof useSQLiteContext>,
key: string,
): Promise<string | null> {
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
[key],
);
return row?.value ?? null;
}
export async function setSetting(
db: ReturnType<typeof useSQLiteContext>,
key: string,
value: string,
): Promise<void> {
await db.runAsync(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value],
);
}
export function useSetting(key: string): string | null {
const db = useSQLiteContext();
const row = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?',
[key],
);
return row?.value ?? null;
}
+2
View File
@@ -0,0 +1,2 @@
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
+37
View File
@@ -0,0 +1,37 @@
{
"name": "bincio",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "expo lint"
},
"dependencies": {
"expo": "~55.0.0",
"expo-router": "~55.0.0",
"expo-sqlite": "~55.0.0",
"expo-document-picker": "~55.0.0",
"expo-file-system": "~55.0.0",
"expo-background-fetch": "~55.0.0",
"expo-task-manager": "~55.0.0",
"expo-notifications": "~55.0.0",
"expo-constants": "~55.0.0",
"expo-linking": "~55.0.0",
"expo-splash-screen": "~55.0.0",
"expo-status-bar": "~55.0.0",
"react": "19.0.0",
"react-native": "0.85.2",
"react-native-safe-area-context": "^5.0.0",
"react-native-screens": "^4.0.0",
"react-native-webview": "~13.16.0",
"@maplibre/maplibre-react-native": "~11.0.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~19.0.0",
"typescript": "~5.8.0"
}
}
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Bincio mobile app — one-time setup
# Run from the mobile/ directory: ./setup.sh
# Or from the repo root: bash mobile/setup.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── Colours ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
ok() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*"; }
die() { echo -e "${RED}${RESET} $*" >&2; exit 1; }
step() { echo -e "\n${YELLOW}${RESET} $*"; }
echo ""
echo " Bincio mobile setup"
echo " ═══════════════════"
echo ""
# ── 1. Node.js ────────────────────────────────────────────────────────────────
step "Checking Node.js..."
if ! command -v node &>/dev/null; then
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
fi
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -lt 18 ]; then
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
fi
ok "Node.js $(node -v)"
# ── 2. npm ────────────────────────────────────────────────────────────────────
if ! command -v npm &>/dev/null; then
die "npm not found. It ships with Node.js — check your installation."
fi
ok "npm $(npm -v)"
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
step "Checking Expo CLI..."
if command -v expo &>/dev/null; then
ok "Expo CLI $(expo --version) (global)"
else
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
warn "Install globally with: npm install -g expo-cli"
fi
# ── 4. Platform tools ─────────────────────────────────────────────────────────
step "Checking platform tools..."
PLATFORM="$(uname -s)"
if [ "$PLATFORM" = "Darwin" ]; then
if command -v xcodebuild &>/dev/null; then
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
else
warn "Xcode not found — iOS builds will not work."
warn "Install Xcode from the App Store, then: xcode-select --install"
fi
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
ok "iOS SDK available"
fi
fi
if command -v adb &>/dev/null; then
ok "Android SDK / adb found"
else
warn "adb not found — Android builds require Android Studio."
warn "Install from https://developer.android.com/studio"
fi
# ── 5. Install dependencies ───────────────────────────────────────────────────
step "Installing npm dependencies..."
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
ok "node_modules already present — running npm install to sync..."
fi
npm install
ok "Dependencies installed"
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
step "Generating Expo type declarations..."
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
if [ ! -f expo-env.d.ts ]; then
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
fi
ok "expo-env.d.ts ready"
# ── 7. Summary ────────────────────────────────────────────────────────────────
echo ""
echo " ══════════════════════════════════════════"
echo " Setup complete! Next steps:"
echo ""
echo " Start with Expo Go (scan QR on your phone):"
echo " npx expo start"
echo ""
echo " Run on Android emulator:"
echo " npx expo run:android"
echo ""
echo " Run on iOS simulator (macOS only):"
echo " npx expo run:ios"
echo ""
echo " Build APK for Karoo sideload:"
echo " npx eas build -p android --profile preview"
echo " ══════════════════════════════════════════"
echo ""
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"expo-env.d.ts"
]
}