diff --git a/bincio/serve/server.py b/bincio/serve/server.py
index d69ec15..ff78399 100644
--- a/bincio/serve/server.py
+++ b/bincio/serve/server.py
@@ -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)
async def login(
login_req: LoginRequest,
diff --git a/docs/mobile-app.md b/docs/mobile-app.md
index a4ce915..3f0a3a2 100644
--- a/docs/mobile-app.md
+++ b/docs/mobile-app.md
@@ -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
The mobile app lives in `mobile/` inside the main bincio repository (Option A).
diff --git a/mobile/.gitignore b/mobile/.gitignore
new file mode 100644
index 0000000..d8dac76
--- /dev/null
+++ b/mobile/.gitignore
@@ -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
diff --git a/mobile/app.json b/mobile/app.json
new file mode 100644
index 0000000..1c15c6a
--- /dev/null
+++ b/mobile/app.json
@@ -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"
+ ]
+ }
+}
diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..bb1e62f
--- /dev/null
+++ b/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,32 @@
+import { Tabs } from 'expo-router';
+
+export default function TabLayout() {
+ return (
+
+ }}
+ />
+ }}
+ />
+ }}
+ />
+
+ );
+}
+
+function TabIcon({ label, color }: { label: string; color: string }) {
+ const { Text } = require('react-native');
+ return {label};
+}
diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx
new file mode 100644
index 0000000..ac9c3ac
--- /dev/null
+++ b/mobile/app/(tabs)/import.tsx
@@ -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({ 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 },
+});
diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx
new file mode 100644
index 0000000..4a5fab3
--- /dev/null
+++ b/mobile/app/(tabs)/index.tsx
@@ -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 (
+
+ 🚴
+ No activities yet
+
+ Go to Import to add a FIT, GPX, or TCX file.
+
+
+ );
+ }
+
+ return (
+
+ Feed
+ a.id}
+ renderItem={({ item }) => }
+ contentContainerStyle={styles.list}
+ />
+
+ );
+}
+
+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 (
+ router.push(`/activity/${activity.id}`)}
+ >
+
+ {sportIcon(activity.sport)}
+
+ {date}
+ {!activity.synced_at && activity.origin === 'local' && (
+ local
+ )}
+
+
+ {activity.title}
+
+ {km && }
+ {elev != null && }
+
+
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function sportIcon(sport: string): string {
+ const icons: Record = {
+ 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 },
+});
diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..3f99ca5
--- /dev/null
+++ b/mobile/app/(tabs)/settings.tsx
@@ -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 (
+
+ Settings
+
+
+
+
+
+ Leave blank to use the app without a remote instance. When set, you can
+ push activities to the instance and pull the web feed.
+
+
+
+ {Platform.OS === 'android' && (
+
+
+
+ New FIT files in this directory are imported automatically in the
+ background. Leave blank to disable. Requires storage permission.
+
+
+ )}
+
+
+
+ {saved ? '✓ Saved' : 'Save'}
+
+
+
+
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function Field({
+ label, placeholder, value, onChangeText, ...rest
+}: {
+ label: string;
+ placeholder: string;
+ value: string;
+ onChangeText: (v: string) => void;
+ [key: string]: unknown;
+}) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+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 },
+});
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
new file mode 100644
index 0000000..a2c9de5
--- /dev/null
+++ b/mobile/app/_layout.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx
new file mode 100644
index 0000000..30edb45
--- /dev/null
+++ b/mobile/app/activity/[id].tsx
@@ -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 (
+
+ Activity not found
+
+ );
+ }
+
+ 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 (
+
+ router.back()}>
+ ← Back
+
+
+ {detail.sport ?? 'Activity'}
+ {detail.title}
+ {date}
+
+ {/* Map placeholder — Phase 1 */}
+
+ Map · Phase 1
+
+
+ {/* Stats grid */}
+
+ {km && }
+ {movingTime && }
+ {elev != null && }
+ {elevLoss != null && }
+ {speed && }
+ {hr && }
+ {power && }
+
+
+ {/* Elevation chart placeholder — Phase 1 */}
+ {row.timeseries_json && (
+
+ Elevation chart · Phase 1
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
+ return (
+
+
+ {value}
+ {unit ? {unit} : null}
+
+ {label}
+
+ );
+}
+
+function MetaRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+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 },
+});
diff --git a/mobile/babel.config.js b/mobile/babel.config.js
new file mode 100644
index 0000000..9d89e13
--- /dev/null
+++ b/mobile/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/mobile/db/index.ts b/mobile/db/index.ts
new file mode 100644
index 0000000..2abdd22
--- /dev/null
+++ b/mobile/db/index.ts
@@ -0,0 +1,26 @@
+import type { SQLiteDatabase } from 'expo-sqlite';
+
+export async function migrateDb(db: SQLiteDatabase): Promise {
+ 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
+ );
+ `);
+}
diff --git a/mobile/db/queries.ts b/mobile/db/queries.ts
new file mode 100644
index 0000000..52c710b
--- /dev/null
+++ b/mobile/db/queries.ts
@@ -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(
+ 'SELECT * FROM activities WHERE id = ?',
+ [id],
+ ) ?? null;
+}
+
+export async function insertActivity(
+ db: ReturnType,
+ row: Pick,
+): Promise {
+ 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,
+ key: string,
+): Promise {
+ const row = db.getFirstSync<{ value: string }>(
+ 'SELECT value FROM settings WHERE key = ?',
+ [key],
+ );
+ return row?.value ?? null;
+}
+
+export async function setSetting(
+ db: ReturnType,
+ key: string,
+ value: string,
+): Promise {
+ 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;
+}
diff --git a/mobile/metro.config.js b/mobile/metro.config.js
new file mode 100644
index 0000000..74aeaec
--- /dev/null
+++ b/mobile/metro.config.js
@@ -0,0 +1,2 @@
+const { getDefaultConfig } = require('expo/metro-config');
+module.exports = getDefaultConfig(__dirname);
diff --git a/mobile/package.json b/mobile/package.json
new file mode 100644
index 0000000..ec87afa
--- /dev/null
+++ b/mobile/package.json
@@ -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"
+ }
+}
diff --git a/mobile/setup.sh b/mobile/setup.sh
new file mode 100755
index 0000000..d530f66
--- /dev/null
+++ b/mobile/setup.sh
@@ -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 '/// ' > 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 ""
diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json
new file mode 100644
index 0000000..9e9ed52
--- /dev/null
+++ b/mobile/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.d.ts",
+ "expo-env.d.ts"
+ ]
+}