feat(mobile): Karoo GPU crash fix, server-side extraction, upload fix, feed redesign

- Skip MapLibre on Android <29 (Karoo): SELinux denies kgsl-3d0 access
  from untrusted_app context, crashing the GPU driver on any OpenGL
  surface. Replace with SvgRouteView — equirectangular SVG route trace
  using react-native-svg, no native GL surface needed.
- Add +/- zoom buttons to full-screen MapLibre map on modern devices
  via Camera ref and onRegionDidChange.
- Skip PyodideWebView on Android <29: same GPU driver conflict; set
  _engineUnavailable at module init via API level gate (< 29).
- Add engine_unavailable fast path in PyodideWebView: post message
  immediately if WebAssembly.Global is absent (Chrome <69) instead of
  attempting 30 MB Pyodide download.
- Add server-side extraction fallback (extractServer.ts): when engine
  unavailable, POST raw file as base64 to /api/upload/raw; server runs
  full Python pipeline and returns extracted data.
- Add /api/upload/raw endpoint in server.py.
- Add pre-flight auth check (checkServerAuth) before batch import so
  an expired token errors immediately rather than after N files.
- Fix uploadLocalActivities in sync.ts: was reading original_path as
  JSON (binary FIT file, always threw), silently skipping every upload.
  Now reads detail_json from DB directly.
- Redesign Feed header: replace single Sync button with Upload /
  Download / Refresh. Pull-to-refresh and Refresh button are local-only.
  Auto-refresh on tab focus via useFocusEffect.
- Replace ActivityIndicator with plain Text everywhere (native animation
  also crashes Karoo GPU driver).
- Raise macOS open-file limit in dev_test.py to prevent EMFILE errors
  from Astro file watcher.
- Document all Karoo hardware constraints in docs/mobile-app.md.
This commit is contained in:
Davide Scaini
2026-04-26 21:00:12 +02:00
parent 4cabbea0d4
commit cbe3e0eeaf
10 changed files with 760 additions and 156 deletions
+111 -29
View File
@@ -6,7 +6,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
import { PyodideWebView } from '@/extraction/PyodideWebView';
import { extractFile, waitForEngine } from '@/extraction/extractActivity';
import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from '@/extraction/extractActivity';
import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer';
import { useTheme } from '@/ThemeContext';
const FIT_EXTENSIONS = ['.fit', '.fit.gz'];
@@ -24,8 +25,18 @@ export default function ImportScreen() {
const theme = useTheme();
const [state, setState] = useState<ImportState>({ status: 'idle' });
const [watchPath, setWatchPath] = useState('');
const [engineAvailable, setEngineAvailable] = useState<boolean | null>(null);
const isImporting = useRef(false);
// Track engine availability so we can show the server-extraction notice.
useEffect(() => {
waitForEngine(30_000)
.then(() => setEngineAvailable(true))
.catch((e: unknown) => {
if (e instanceof Error && e.message === 'engine_unavailable') setEngineAvailable(false);
});
}, []);
// Reload watch path every time the Import tab comes into focus so changes
// saved in Settings are picked up without remounting the tab.
useFocusEffect(useCallback(() => {
@@ -56,8 +67,18 @@ export default function ImportScreen() {
const instanceUrl = await getSetting(db, 'instance_url');
if (!instanceUrl) return;
// Wait for the extraction engine — but don't block forever on auto-scan.
try { await waitForEngine(120_000); } catch { return; }
// Wait for engine — skip auto-scan on init failure, but continue if device is
// too old for local extraction (importNativeFile will use the server instead).
try { await waitForEngine(120_000); } catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') return;
}
// Server-mode requires a valid token — verify before touching any files.
if (isEngineAvailable() === false) {
const token = await getSetting(db, 'api_token');
if (!token) return;
try { await checkServerAuth(instanceUrl, token); } catch { return; }
}
const newFiles = await discoverNewFiles(db, path);
if (newFiles.length === 0) return;
@@ -80,12 +101,37 @@ export default function ImportScreen() {
return;
}
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
try {
await waitForEngine();
} catch (e: unknown) {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
const serverMode = isEngineAvailable() === false;
if (!serverMode) {
setState({ status: 'loading', msg: 'Preparing extraction engine…', current: 0, total: 0 });
const unsubScan = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await waitForEngine();
} catch (e: unknown) {
if (!(e instanceof Error) || e.message !== 'engine_unavailable') {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
// engine_unavailable — fall through to server mode
} finally {
unsubScan();
}
} else {
const token = await getSetting(db, 'api_token');
if (!token) {
setState({ status: 'error', message: 'Server extraction requires a Bincio account. Connect in Settings.' });
return;
}
// Verify the token is valid before processing any files.
setState({ status: 'loading', msg: 'Checking connection…', current: 0, total: 0 });
try {
await checkServerAuth(instanceUrl, token);
} catch (e: unknown) {
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
return;
}
}
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
@@ -132,9 +178,13 @@ export default function ImportScreen() {
return;
}
isImporting.current = true;
const unsubPick = onEngineProgress((msg) =>
setState({ status: 'loading', msg, current: 0, total: 0 }),
);
try {
await processBatch(result.assets.map(a => ({ uri: a.uri, name: a.name ?? '', sourcePath: null })));
} finally {
unsubPick();
isImporting.current = false;
}
} catch (e: unknown) {
@@ -211,7 +261,7 @@ export default function ImportScreen() {
});
}
// ── FIT / GPX / TCX import via Pyodide extraction ──────────────────────────
// ── FIT / GPX / TCX import via Pyodide (local) or server fallback ───────────
async function importNativeFile(
uri: string,
@@ -221,20 +271,32 @@ export default function ImportScreen() {
) {
onStatus('Reading file…');
// Read the original file as base64 so we can (a) pass it to the WebView
// Read the original file as base64 so we can (a) pass it to the extractor
// and (b) copy it to permanent storage without a second read.
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
// Fetch the bincio wheel here (React Native networking), not inside the
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
const instanceUrl = await getInstanceUrl(db);
onStatus('Fetching Bincio engine…');
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
let result;
const result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
if (isEngineAvailable() === false) {
// Device WebView is too old for WebAssembly.Global (Chrome <69).
// Send the raw file to the Bincio instance for server-side extraction.
const instanceUrl = await getInstanceUrl(db);
const token = db.getFirstSync<{ value: string }>(
'SELECT value FROM settings WHERE key = ?', ['api_token'],
)?.value ?? '';
if (!token) throw new Error('Server extraction requires a Bincio account — connect in Settings.');
result = await extractFileViaServer(name, base64, instanceUrl, token, onStatus);
} else {
// Fetch the bincio wheel here (React Native networking), not inside the
// WebView. WKWebView blocks HTTP requests via ATS; RN native networking
// allows local-network HTTP (NSAllowsLocalNetworking=true in Info.plist).
const instanceUrl = await getInstanceUrl(db);
onStatus('Fetching Bincio engine…');
const { base64: wheelBase64, filename: wheelFilename } = await fetchWheelBase64(instanceUrl);
result = await extractFile(name, base64, wheelBase64, wheelFilename, onStatus);
}
onStatus('Saving…');
@@ -259,12 +321,14 @@ export default function ImportScreen() {
return (
<View style={styles.screen}>
{/* Hidden WebView for Pyodide — mounted here so it lives inside the tab
(Expo Router keeps tabs mounted after first visit, preserving Pyodide state).
The 1×1 container clips it out of the scroll layout entirely. */}
<View style={styles.hiddenEngine}>
<PyodideWebView />
</View>
{/* Hidden WebView for Pyodide — only mounted on devices that can run it.
Android <29 has a system WebView (Chrome <69) that lacks WebAssembly.Global
AND causes GPU SurfaceView crashes on old drivers. Skip it entirely there. */}
{(Platform.OS !== 'android' || (Platform.Version as number) >= 29) && (
<View style={styles.hiddenEngine}>
<PyodideWebView />
</View>
)}
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Import</Text>
@@ -273,6 +337,15 @@ export default function ImportScreen() {
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
</Text>
{engineAvailable === false && (
<View style={styles.serverNotice}>
<Text style={styles.serverNoticeText}>
This device's Android WebView is too old to run local extraction (requires Chrome 69+).
Activities are processed by your Bincio instance instead — a connected account is required.
</Text>
</View>
)}
{watchPath ? (
<View style={styles.watchBox}>
<Text style={styles.watchLabel}>Watch folder</Text>
@@ -305,9 +378,11 @@ export default function ImportScreen() {
</Text>
)}
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</Text>
{engineAvailable !== false && (
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</Text>
)}
</View>
)}
@@ -353,8 +428,10 @@ export default function ImportScreen() {
<View style={styles.notice}>
<Text style={styles.noticeText}>
FIT/GPX/TCX extraction runs entirely on your device.{'\n'}
A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'}
{engineAvailable === false
? 'Activities are sent to your Bincio instance for extraction and stored there + locally. A connected account is required.'
: `FIT/GPX/TCX extraction runs entirely on your device.\nA Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).`}
{'\n\n'}
On Karoo: set Watch directory to <Text style={styles.noticeCode}>/sdcard/FitFiles</Text> in Settings to auto-import rides.
</Text>
</View>
@@ -469,6 +546,11 @@ const styles = StyleSheet.create({
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
code: { color: '#60a5fa', fontFamily: 'monospace' },
serverNotice: {
backgroundColor: '#1c1400', borderRadius: 8, borderWidth: 1,
borderColor: '#854d0e', padding: 12, marginBottom: 16,
},
serverNoticeText: { color: '#fbbf24', fontSize: 13, lineHeight: 18 },
watchBox: {
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10,
+121 -48
View File
@@ -1,42 +1,73 @@
import * as FileSystem from 'expo-file-system';
import { useFocusEffect } from 'expo-router';
import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
import { syncFeed } from '@/db/sync';
import { downloadFeed, uploadFeed } from '@/db/sync';
import { useTheme } from '@/ThemeContext';
export default function FeedScreen() {
const db = useSQLiteContext();
const theme = useTheme();
const [refreshKey, setRefreshKey] = useState(0);
const activities = useActivities();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [uploading, setUploading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const selecting = selected.size > 0;
const doSync = useCallback(async () => {
setSyncing(true);
setSyncMsg(null);
const result = await syncFeed(db);
setSyncing(false);
// Auto-refresh the local list whenever the tab comes into focus.
// SQLite getAllSync is sub-millisecond — no network, no lag.
useFocusEffect(useCallback(() => {
setRefreshKey(k => k + 1);
}, []));
function showMsg(ok: boolean, text: string) {
setStatusMsg({ ok, text });
setTimeout(() => setStatusMsg(null), 3500);
}
const doDownload = useCallback(async () => {
setDownloading(true);
setStatusMsg(null);
const result = await downloadFeed(db);
setDownloading(false);
setRefreshKey(k => k + 1);
if (result.error) {
setSyncMsg(result.error);
showMsg(false, result.error);
} else if (result.total === 0) {
setSyncMsg('No activities on instance');
} else if (result.synced === 0 && !result.fetched && !result.uploaded) {
setSyncMsg(`Up to date (${result.total} activities)`);
showMsg(true, 'No activities on instance');
} else if (result.synced === 0 && !result.fetched) {
showMsg(true, `Up to date (${result.total} activities)`);
} else {
const parts = [];
if (result.synced > 0) parts.push(`${result.synced} new`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
if (result.uploaded) parts.push(`${result.uploaded} uploaded`);
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
}
setTimeout(() => setSyncMsg(null), 3500);
}, [db]);
const doUpload = useCallback(async () => {
setUploading(true);
setStatusMsg(null);
const result = await uploadFeed(db);
setUploading(false);
if (result.error) {
showMsg(false, result.error);
} else if (!result.uploaded) {
showMsg(true, 'Nothing to upload');
} else {
showMsg(true, `Uploaded ${result.uploaded} activit${result.uploaded === 1 ? 'y' : 'ies'}`);
}
}, [db]);
function doRefresh() {
setRefreshKey(k => k + 1);
}
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev);
@@ -45,9 +76,7 @@ export default function FeedScreen() {
});
}
function cancelSelect() {
setSelected(new Set());
}
function cancelSelect() { setSelected(new Set()); }
function confirmDeleteSelected() {
const count = selected.size;
@@ -64,9 +93,7 @@ export default function FeedScreen() {
const paths = await deleteActivities(db, ids);
setSelected(new Set());
for (const p of paths) {
if (p) {
try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
}
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
}
},
},
@@ -74,6 +101,8 @@ export default function FeedScreen() {
);
}
const busy = downloading || uploading;
return (
<View style={styles.container}>
<View style={styles.headerRow}>
@@ -87,32 +116,56 @@ export default function FeedScreen() {
) : (
<>
<Text style={styles.header}>Feed</Text>
<Pressable
style={[styles.syncButton, { backgroundColor: theme.dim }, syncing && styles.syncButtonDisabled]}
onPress={syncing ? undefined : doSync}
>
<Text style={[styles.syncText, { color: theme.accent }]}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
</Pressable>
<View style={styles.actionButtons}>
<ActionButton
icon="↑"
label="Upload"
loading={uploading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doUpload}
/>
<ActionButton
icon="↓"
label="Download"
loading={downloading}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doDownload}
/>
<ActionButton
icon="↺"
label="Refresh"
loading={false}
disabled={busy}
accent={theme.accent}
dim={theme.dim}
onPress={doRefresh}
/>
</View>
</>
)}
</View>
{syncMsg && (
<Text style={styles.syncMsg}>{syncMsg}</Text>
{statusMsg && (
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
)}
{activities.length === 0 && !syncing ? (
{activities.length === 0 && !busy ? (
<View style={styles.empty}>
<Text style={styles.emptyIcon}>🚴</Text>
<Text style={styles.emptyTitle}>No activities yet</Text>
<Text style={styles.emptyBody}>
Import a file or tap Sync to pull from your instance.
Import a file or tap to pull from your instance.
</Text>
</View>
) : (
<FlatList
data={activities}
keyExtractor={(a) => a.id}
extraData={refreshKey}
renderItem={({ item }) => (
<ActivityCard
activity={item}
@@ -125,8 +178,8 @@ export default function FeedScreen() {
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={doSync}
refreshing={false}
onRefresh={doRefresh}
tintColor="#60a5fa"
/>
}
@@ -144,6 +197,30 @@ export default function FeedScreen() {
);
}
function ActionButton({
icon, label, loading, disabled, accent, dim, onPress,
}: {
icon: string;
label: string;
loading: boolean;
disabled: boolean;
accent: string;
dim: string;
onPress: () => void;
}) {
return (
<Pressable
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
onPress={disabled ? undefined : onPress}
accessibilityLabel={label}
>
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
{loading ? '…' : icon}
</Text>
</Pressable>
);
}
function ActivityCard({
activity,
selecting,
@@ -166,11 +243,8 @@ function ActivityCard({
});
function handlePress() {
if (selecting) {
onToggleSelect();
} else {
router.push(`/activity/${activity.id}`);
}
if (selecting) onToggleSelect();
else router.push(`/activity/${activity.id}`);
}
return (
@@ -228,21 +302,20 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
},
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
syncButton: {
borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
actionButtons: { flexDirection: 'row', gap: 8 },
actionBtn: {
width: 36, height: 36, borderRadius: 8,
alignItems: 'center', justifyContent: 'center',
},
syncButtonDisabled: { opacity: 0.5 },
syncText: { fontSize: 13, fontWeight: '600' },
actionBtnDisabled: { opacity: 0.4 },
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
cancelButton: {
backgroundColor: '#27272a', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
syncMsg: {
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
paddingHorizontal: 16, paddingBottom: 8,
},
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
list: { padding: 16, gap: 12, paddingBottom: 80 },
card: {
backgroundColor: '#18181b', borderRadius: 12,
+91 -24
View File
@@ -1,8 +1,8 @@
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
import * as FileSystem from 'expo-file-system';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useEffect, useRef, useState } from 'react';
import { Alert, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
import { useSQLiteContext } from 'expo-sqlite';
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
@@ -161,16 +161,25 @@ export default function ActivityScreen() {
function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) {
const [fullscreen, setFullscreen] = useState(false);
const [currentZoom, setCurrentZoom] = useState(12);
const cameraRef = useRef<any>(null);
if (loading) {
return (
<View style={styles.mapPlaceholder}>
<ActivityIndicator color={accent} />
<Text style={{ color: accent, fontSize: 13 }}>Loading map</Text>
</View>
);
}
if (!geojson) return null;
// MapLibre uses OpenGL/SurfaceView which crashes the Karoo's Qualcomm GPU
// driver (Android <29) even without any interaction. Render a pure SVG route
// trace instead — no native GL surface, no crash.
if (Platform.OS === 'android' && (Platform.Version as number) < 29) {
return <SvgRouteView geojson={geojson} accent={accent} />;
}
const bounds = geoJsonBounds(geojson);
const routeSource = (
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
@@ -182,28 +191,16 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
/>
</GeoJSONSource>
);
const camera = bounds ? (
<Camera
initialViewState={{
bounds,
padding: { top: 24, bottom: 24, left: 24, right: 24 },
}}
/>
) : null;
const cameraBounds = bounds
? { bounds, padding: { top: 24, bottom: 24, left: 24, right: 24 } }
: undefined;
return (
<>
{/* Thumbnail — tap to expand */}
<Pressable style={styles.mapContainer} onPress={() => setFullscreen(true)}>
<Map
style={styles.map}
mapStyle={MAP_STYLE}
dragPan={false}
touchZoom={false}
touchPitch={false}
touchRotate={false}
>
{camera}
<Map style={styles.map} mapStyle={MAP_STYLE} dragPan={false} touchZoom={false} touchPitch={false} touchRotate={false}>
{cameraBounds && <Camera initialViewState={cameraBounds} />}
{routeSource}
</Map>
<View style={styles.mapExpandHint}>
@@ -211,22 +208,89 @@ function RouteMap({ geojson, loading, accent }: { geojson: object | null; loadin
</View>
</Pressable>
{/* Full-screen interactive map */}
{/* Full-screen map with +/- zoom buttons */}
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
<View style={styles.fullscreenMap}>
<Map style={styles.map} mapStyle={MAP_STYLE}>
{camera}
<Map
style={styles.map}
mapStyle={MAP_STYLE}
onRegionDidChange={(e: any) => {
const z = e?.properties?.zoomLevel;
if (typeof z === 'number') setCurrentZoom(z);
}}
>
{cameraBounds && <Camera ref={cameraRef} initialViewState={cameraBounds} />}
{routeSource}
</Map>
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
<Text style={styles.closeText}></Text>
</Pressable>
<View style={styles.zoomButtons}>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: currentZoom + 1, animationDuration: 200 })}>
<Text style={styles.zoomBtnText}>+</Text>
</Pressable>
<Pressable style={styles.zoomBtn} onPress={() => cameraRef.current?.setCamera({ zoomLevel: Math.max(1, currentZoom - 1), animationDuration: 200 })}>
<Text style={styles.zoomBtnText}></Text>
</Pressable>
</View>
</View>
</Modal>
</>
);
}
// SVG route trace — used on Android <29 where MapLibre crashes the GPU driver.
// Renders the GPS track as a colored path on a dark background with no tiles.
function SvgRouteView({ geojson, accent }: { geojson: object; accent: string }) {
const W = 320;
const H = 180;
const PAD = 16;
const all: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { all.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => all.push(...c)); return; }
}
collect(geojson);
if (!all.length) return null;
const step = Math.max(1, Math.floor(all.length / 500));
const pts = all.filter((_, i) => i % step === 0);
const lons = pts.map(c => c[0]);
const lats = pts.map(c => c[1]);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const spanLon = maxLon - minLon || 0.001;
const spanLat = maxLat - minLat || 0.001;
// Correct longitude for latitude (equirectangular)
const midLat = (minLat + maxLat) / 2;
const lonFactor = Math.cos((midLat * Math.PI) / 180);
const adjLon = spanLon * lonFactor;
const scale = Math.min((W - PAD * 2) / adjLon, (H - PAD * 2) / spanLat);
const offX = (W - adjLon * scale) / 2;
const offY = (H - spanLat * scale) / 2;
const toX = (lon: number) => offX + (lon - minLon) * lonFactor * scale;
const toY = (lat: number) => H - offY - (lat - minLat) * scale;
const d = pts.map((c, i) => `${i === 0 ? 'M' : 'L'}${toX(c[0]).toFixed(1)},${toY(c[1]).toFixed(1)}`).join(' ');
return (
<View style={[styles.mapContainer, { alignItems: 'center', justifyContent: 'center' }]}>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Path d={d} fill="none" stroke={accent} strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" />
</Svg>
</View>
);
}
// ── Metric charts ─────────────────────────────────────────────────────────────
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
@@ -245,7 +309,7 @@ function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<ActivityIndicator color={accent} />
<Text style={{ color: accent, fontSize: 13 }}>Loading chart</Text>
</View>
);
}
@@ -414,6 +478,9 @@ const styles = StyleSheet.create({
fullscreenMap: { flex: 1, backgroundColor: '#09090b' },
closeButton: { position: 'absolute', top: 56, right: 16, backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
closeText: { color: '#fff', fontSize: 16 },
zoomButtons: { position: 'absolute', bottom: 40, right: 16, gap: 8 },
zoomBtn: { backgroundColor: 'rgba(0,0,0,0.65)', borderRadius: 20, width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
zoomBtnText: { color: '#fff', fontSize: 22, fontWeight: '600', lineHeight: 28 },
chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', overflow: 'hidden' },
chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
chartTabs: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#27272a' },