From b37df88fe13df20b6ab6f00ad1279c7c96b6c5cd Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 24 Apr 2026 10:39:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=200=20mobile=20app=20scaffold=20?= =?UTF-8?q?=E2=80=94=20Expo=2055,=20SQLite,=20Feed/Import/Settings=20scree?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bincio/serve/server.py | 14 +++ docs/mobile-app.md | 45 +++++++++ mobile/.gitignore | 18 ++++ mobile/app.json | 36 +++++++ mobile/app/(tabs)/_layout.tsx | 32 +++++++ mobile/app/(tabs)/import.tsx | 170 +++++++++++++++++++++++++++++++++ mobile/app/(tabs)/index.tsx | 115 ++++++++++++++++++++++ mobile/app/(tabs)/settings.tsx | 159 ++++++++++++++++++++++++++++++ mobile/app/_layout.tsx | 13 +++ mobile/app/activity/[id].tsx | 164 +++++++++++++++++++++++++++++++ mobile/babel.config.js | 6 ++ mobile/db/index.ts | 26 +++++ mobile/db/queries.ts | 120 +++++++++++++++++++++++ mobile/metro.config.js | 2 + mobile/package.json | 37 +++++++ mobile/setup.sh | 104 ++++++++++++++++++++ mobile/tsconfig.json | 15 +++ 17 files changed, 1076 insertions(+) create mode 100644 mobile/.gitignore create mode 100644 mobile/app.json create mode 100644 mobile/app/(tabs)/_layout.tsx create mode 100644 mobile/app/(tabs)/import.tsx create mode 100644 mobile/app/(tabs)/index.tsx create mode 100644 mobile/app/(tabs)/settings.tsx create mode 100644 mobile/app/_layout.tsx create mode 100644 mobile/app/activity/[id].tsx create mode 100644 mobile/babel.config.js create mode 100644 mobile/db/index.ts create mode 100644 mobile/db/queries.ts create mode 100644 mobile/metro.config.js create mode 100644 mobile/package.json create mode 100755 mobile/setup.sh create mode 100644 mobile/tsconfig.json 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" + ] +}