From 97c7fae9bee6dd5b903c5f339b9d8878edd6d46a Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 24 Apr 2026 15:40:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20MapLibre=20rout?= =?UTF-8?q?e=20map=20+=20SVG=20elevation=20chart=20on=20activity=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints (bearer-token-gated, falls back from _merged to raw activities dir) - Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera, GeoJSONSource, Layer) and react-native-svg area chart with gradient fill - On-demand fetch for remote activities that have no local geojson/timeseries - Add react-native-svg dependency; requires dev build (npx expo run:android) --- bincio/serve/server.py | 32 ++++ docs/mobile-app.md | 150 +++++++++------- mobile/app/activity/[id].tsx | 331 ++++++++++++++++++++++++----------- mobile/package-lock.json | 157 +++++++++++++++++ mobile/package.json | 1 + 5 files changed, 507 insertions(+), 164 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index b030aa7..c6a93f2 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -455,6 +455,38 @@ async def stats() -> JSONResponse: }) +@app.get("/api/activity/{activity_id}/geojson") +async def get_activity_geojson( + activity_id: str, + user: User = Depends(_require_auth), +) -> JSONResponse: + """Return GeoJSON track for an activity (mobile detail screen).""" + _check_id(activity_id) + dd = _get_data_dir() + user_dir = dd / user.handle + for base in (user_dir / "_merged" / "activities", user_dir / "activities"): + p = base / f"{activity_id}.geojson" + if p.exists(): + return JSONResponse(json.loads(p.read_text())) + raise HTTPException(404, "GeoJSON not found") + + +@app.get("/api/activity/{activity_id}/timeseries") +async def get_activity_timeseries( + activity_id: str, + user: User = Depends(_require_auth), +) -> JSONResponse: + """Return timeseries for an activity (mobile detail screen).""" + _check_id(activity_id) + dd = _get_data_dir() + user_dir = dd / user.handle + for base in (user_dir / "_merged" / "activities", user_dir / "activities"): + p = base / f"{activity_id}.timeseries.json" + if p.exists(): + return JSONResponse(json.loads(p.read_text())) + raise HTTPException(404, "Timeseries not found") + + @app.get("/api/wheel/version") async def wheel_version() -> JSONResponse: """Public endpoint: current bincio wheel version for mobile app update checks.""" diff --git a/docs/mobile-app.md b/docs/mobile-app.md index 1f7cb08..d277d9e 100644 --- a/docs/mobile-app.md +++ b/docs/mobile-app.md @@ -76,11 +76,13 @@ instantly via Metro (the JS bundler) — no rebuild needed. | Code changes | instant (Metro) | instant (Metro) | | Native changes | need new Expo Go release | rebuild APK | -**Phase 0** only uses built-in Expo modules — Expo Go works. **Phase 1** (Pyodide) -requires a Development Build because `react-native-webview` is a native module. +**Phase 0 and 0.5** only use built-in Expo modules — Expo Go works. **Phase 1** +(Pyodide) and **Phase 4** (MapLibre maps) require a Development Build because +`react-native-webview` and `@maplibre/maplibre-react-native` are native modules. -The recommended setup from the start is a Development Build so you never hit a wall -mid-phase. +The preferred path for Phase 1+: connect the phone via USB and run +`npx expo run:android` once. After that, JS changes still update instantly via Metro +— no rebuild needed unless you change native code. --- @@ -147,25 +149,17 @@ reflect instantly without rebuilding. For the emulator, create an AVD in Android Studio with API 33+ and start it before running the command. -#### Option B: EAS Build (cloud, no Android Studio required) +#### Option B: local EAS build (no Android Studio, no external cloud) -EAS (Expo Application Services) builds the APK in the cloud. You get a download -link; install it on your device once. +`eas build --local` runs the entire build pipeline on your own machine (or VPS): ```bash npm install -g eas-cli -eas login # Expo account needed -eas build -p android --profile development +eas build -p android --profile development --local ``` -After install, start Metro locally: - -```bash -cd mobile -npx expo start --dev-client -``` - -Shake the device to open the dev menu and enter the Metro URL if needed. +This produces an `.apk` you can transfer to the device via any means (USB, VPS +download link, AirDrop). No Expo account or cloud service required. --- @@ -520,59 +514,60 @@ The token is stored in the `settings` table and sent as ## Implementation plan -### Phase 0 — Foundation +### Phase 0 — Foundation ✅ *Goal: app launches, settings can be configured, a BAS JSON file can be picked and displayed as an activity card. No extraction yet.* -**`mobile/` scaffold:** -- `expo init mobile --template expo-template-blank-typescript` - Expo Router with three tabs: **Feed**, **Import**, **Settings** - `expo-sqlite` initialised; `activities` and `settings` tables created on first launch -- Settings screen: instance URL and handle fields, saved to `settings` table +- Settings screen: instance URL and handle, saved to `settings` table +- Import screen: `expo-document-picker`; BAS `.json` files parsed and inserted into feed +- Feed screen: activity cards sorted by `started_at`, sport icon, distance, elevation +- `GET /api/wheel/version` server endpoint (public, no auth) -**Import screen (stub):** -- `expo-document-picker` for `.fit`, `.gpx`, `.tcx`, `.json` files -- If a `.json` file is picked: parse as BAS detail, insert into `activities` (no timeseries), show in feed -- This lets the feed work before Pyodide is wired up - -**Feed screen:** -- List of activities from `activities` table, sorted by `started_at` -- Each card: sport icon, title, date, distance, elevation - -**Server (one small addition):** -- `GET /api/wheel/version` → `{ "version": "0.1.0", "url": "/bincio-0.1.0-py3-none-any.whl" }` -- No auth required; the wheel itself is already public - -**Done when:** App launches on a phone, user enters instance URL and handle in -Settings, picks a `.json` BAS file, sees it in the Feed. +**Done when:** App launches, user picks a `.json` BAS file, sees it in the Feed. ✅ --- -### Phase 1 — Import via Pyodide -*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s, see the full ride -with map and chart.* +### Phase 0.5 — Remote feed sync ✅ +*Goal: pull all activities from a remote bincio instance into the local feed.* + +- `POST /api/auth/token` — password login returning a Bearer token (stored in + SQLite; password forgotten immediately after) +- `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns + all activity summaries as JSON +- Settings screen: Connect section (password field + Connect button + status) +- Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote + activities; `syncFeed()` upserts remote summaries without overwriting local imports + +**Done when:** Tap Connect, tap Sync, all instance activities appear in the Feed. ✅ + +--- + +### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide +*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.* + +**Requires a Development Build** (`npx expo run:android` via USB, or +`eas build --local`). Expo Go does not support `react-native-webview`. **Extraction engine (`mobile/extraction/`):** - `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that bootstraps Pyodide - `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed, - download and store wheel in `expo-file-system` app directory + download and store wheel in `expo-file-system` app directory; falls back to + bundled `assets/bincio.whl` for offline / pre-deploy use - `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`, awaits `{ detail, timeseries, geojson }` response - Loading state: "Warming up extractor…" shown only on very first use **Import screen (full):** -- Picks FIT/GPX/TCX, passes to `extractActivity`, stores in SQLite +- Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite + (`detail_json`, `timeseries_json`, `geojson` columns) - Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}` - Duplicate detection via `source_hash` before extraction -**Activity detail screen:** -- Stats grid: distance, moving time, elevation gain/loss, avg speed, avg HR, avg power -- Map: MapLibre React Native with the GeoJSON track overlaid -- Elevation chart: simple SVG line chart from timeseries data - -**Done when:** Drop a FIT file from a Karoo onto the phone, see the full ride -stats, map, and elevation profile within ~5 s. +**Done when:** Pick a FIT file from the Karoo rides folder, see full stats in +the Feed within ~5 s, including map and elevation profile. --- @@ -596,35 +591,60 @@ Bincio within 5 minutes of connecting to WiFi, with no manual action. --- -### Phase 3 — Sync -*Goal: activities recorded on the phone appear on bincio.org after one tap.* +### Phase 3 — Push sync +*Goal: locally imported activities appear on the remote instance after one tap.* -**Server additions:** -- `POST /api/auth/token` — password login returning a Bearer token (long-lived, - stored securely; complements existing cookie auth, does not replace it) +Auth (Bearer token + Connect UI) is already done in Phase 0.5. Remaining work: + +**Server:** +- `POST /api/upload` accepting a raw FIT/GPX/TCX file with Bearer token auth — + same as the existing web upload endpoint but token-gated **App:** -- Login screen: instance URL + handle + password → stores token -- Sync screen: last sync time, unsynced count, **Push** and **Pull** buttons -- Push: iterates unsynced local activities, `POST /api/upload` with original file -- Pull: fetches `index.json`, downloads missing activities, inserts as `origin = 'remote'` -- Progress indicator per activity (useful for first sync with many files) +- Push button (Settings or Feed header): iterates unsynced local activities + (`synced_at IS NULL AND origin = 'local'`), uploads original file, marks synced +- Progress indicator per activity; useful for first push with many files -**Done when:** Tap **Push**, activities appear on bincio.org with correct stats. +**Done when:** Tap **Push**, locally imported activities appear on bincio.org. --- -### Phase 4 — Polish *(ongoing)* +### Phase 4 — Activity detail: map + elevation chart +*Goal: every activity shows a route map and elevation profile.* + +**Requires a Development Build** — `@maplibre/maplibre-react-native` is a native +module. Same dev build used for Phase 1 covers Phase 4. + +**Data strategy (on-demand fetch for remote activities):** +- Local activities (Phase 1 imports): `geojson` and `timeseries_json` stored in + SQLite — map and chart render immediately, no network needed +- Remote activities (Phase 0.5 synced): detail screen fetches + `GET /api/activity/{id}/geojson` and `GET /api/activity/{id}/timeseries` + on first open; both are Bearer-token-gated FastAPI endpoints + +**Server additions:** +- `GET /api/activity/{id}/geojson` — reads `_merged/activities/{id}.geojson` +- `GET /api/activity/{id}/timeseries` — reads `activities/{id}.timeseries.json` + +**App:** +- `@maplibre/maplibre-react-native`: route drawn as GeoJSON LineLayer over a + dark CartoDB base map; camera auto-fits track bounding box +- `react-native-svg`: elevation area chart from `elevation_m` + `t` arrays; + downsampled to ≤300 points; shows min/max elevation labels + +**Done when:** Open any synced or locally imported activity — map and elevation +profile are visible within 1 s (local) or after one network round-trip (remote). + +--- + +### Phase 5 — Polish *(ongoing)* - **Offline map tiles** — bundle or download an MBTiles file for a region; MapLibre supports offline tile sources - **Batch import** — pick a folder (Strava export, Garmin bulk export); import all FIT/GPX files found, with progress bar and per-file status -- **Share sheet** — on Android, intent filter for incoming `.fit`/`.gpx`/`.tcx` - files from other apps; on iOS, Share Extension already set up in Phase 2 -- **Home screen widget** — last activity summary or weekly km total -- **Re-extract** — button in activity detail to re-run Pyodide extraction from - the stored original file (picks up algorithm improvements) +- **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files +- **Re-extract** — button to re-run Pyodide extraction from the stored original file --- diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index 30edb45..8c94340 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -1,11 +1,64 @@ +import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useActivity } from '@/db/queries'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; +import { useActivity, useSetting } from '@/db/queries'; + +const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type Timeseries = { + t: number[]; + elevation_m: (number | null)[]; + lat?: (number | null)[] | null; + lon?: (number | null)[] | null; +}; + +// ── Screen ─────────────────────────────────────────────────────────────────── export default function ActivityScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const row = useActivity(id); + const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? ''; + const token = useSetting('api_token') ?? ''; + + const [geojson, setGeojson] = useState(null); + const [timeseries, setTimeseries] = useState(null); + const [loadingMap, setLoadingMap] = useState(false); + const [loadingChart, setLoadingChart] = useState(false); + + useEffect(() => { + if (!row) return; + + if (row.geojson) { + setGeojson(JSON.parse(row.geojson)); + } else if (row.origin === 'remote' && instanceUrl && token) { + setLoadingMap(true); + fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data) setGeojson(data); }) + .catch(() => {}) + .finally(() => setLoadingMap(false)); + } + + if (row.timeseries_json) { + setTimeseries(JSON.parse(row.timeseries_json)); + } else if (row.origin === 'remote' && instanceUrl && token) { + setLoadingChart(true); + fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data) setTimeseries(data); }) + .catch(() => {}) + .finally(() => setLoadingChart(false)); + } + }, [row?.id]); if (!row) { return ( @@ -16,29 +69,14 @@ export default function ActivityScreen() { } 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, { + 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', }); @@ -52,39 +90,161 @@ export default function ActivityScreen() { {detail.title} {date} - {/* Map placeholder — Phase 1 */} - - Map · Phase 1 - + {/* Map */} + {/* Stats grid */} - {km && } - {movingTime && } - {elev != null && } - {elevLoss != null && } - {speed && } - {hr && } - {power && } + {km && } + {movingTime && } + {elev != null && } + {elevLoss != null && } + {speed && } + {hr && } + {power && } - {/* Elevation chart placeholder — Phase 1 */} - {row.timeseries_json && ( - - Elevation chart · Phase 1 - - )} + {/* Elevation chart */} + + {/* Meta */} - - - - + + + + ); } +// ── Map ─────────────────────────────────────────────────────────────────────── + +function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) { + if (loading) { + return ( + + + + ); + } + if (!geojson) return null; + + const bounds = geoJsonBounds(geojson); + + return ( + + + {bounds && ( + + )} + + + + + + ); +} + +// ── Elevation chart ─────────────────────────────────────────────────────────── + +function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) { + const W = 340; + const H = 100; + const PAD = 4; + + if (loading) { + return ( + + + + ); + } + if (!timeseries) return null; + + const raw = timeseries.elevation_m; + if (!raw || raw.length < 2) return null; + + // Downsample to ≤300 points + const step = Math.max(1, Math.floor(raw.length / 300)); + const times = timeseries.t.filter((_, i) => i % step === 0); + const eles = raw.filter((_, i) => i % step === 0).map(v => v ?? 0); + + const minE = Math.min(...eles); + const maxE = Math.max(...eles); + const range = maxE - minE || 1; + const maxT = times[times.length - 1] || 1; + + const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2); + const y = (e: number) => PAD + (1 - (e - minE) / range) * (H - PAD * 2); + + const pts = times.map((t, i) => `${x(t).toFixed(1)},${y(eles[i]).toFixed(1)}`); + const linePath = `M ${pts.join(' L ')}`; + const areaPath = `M ${x(times[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`; + + return ( + + {Math.round(maxE)} m + + + + + + + + + + + {Math.round(minE)} m + + ); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// Returns [west, south, east, north] per LngLatBounds spec +function geoJsonBounds(gj: object): [number, number, number, number] | null { + const coords: [number, number][] = []; + function collect(obj: unknown) { + if (!obj || typeof obj !== 'object') return; + const o = obj as Record; + if (o.type === 'Feature') { collect(o.geometry); return; } + if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; } + if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; } + if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; } + } + collect(gj); + if (!coords.length) return null; + const lons = coords.map(c => c[0]); + const lats = coords.map(c => c[1]); + return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]; +} + +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')}`; +} + function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) { return ( @@ -106,59 +266,32 @@ function MetaRow({ label, value }: { label: string; value: string }) { ); } -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')}`; -} +// ── Styles ──────────────────────────────────────────────────────────────────── 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 }, + 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 }, + mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' }, + map: { flex: 1 }, + mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 }, + chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 12, alignItems: 'flex-start' }, + chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 }, + chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2 }, + 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/package-lock.json b/mobile/package-lock.json index 031d4cc..fcd8219 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -26,6 +26,7 @@ "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "~15.8.0", "react-native-webview": "13.15.0" }, "devDependencies": { @@ -3555,6 +3556,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4046,6 +4053,56 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4186,6 +4243,61 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4254,6 +4366,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -6477,6 +6601,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -7007,6 +7137,18 @@ "node": ">=10" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -7768,6 +7910,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz", + "integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-webview": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 264f025..cfd9dda 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -27,6 +27,7 @@ "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "~15.8.0", "react-native-webview": "13.15.0", "@maplibre/maplibre-react-native": "~11.0.0" },