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 Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; import { useSQLiteContext } from 'expo-sqlite'; import { deleteActivity, useActivity, useSetting } from '@/db/queries'; import { useTheme } from '@/ThemeContext'; const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; // ── Types ──────────────────────────────────────────────────────────────────── type Timeseries = { t: number[]; elevation_m: (number | null)[]; speed_kmh?: (number | null)[] | null; hr_bpm?: (number | null)[] | null; cadence_rpm?: (number | null)[] | null; power_w?: (number | null)[] | null; lat?: (number | null)[] | null; lon?: (number | null)[] | null; }; // ── Screen ─────────────────────────────────────────────────────────────────── export default function ActivityScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const db = useSQLiteContext(); const theme = useTheme(); 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); async function confirmDelete() { Alert.alert( 'Delete activity', 'This will permanently remove this activity from your device.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: async () => { const originalPath = await deleteActivity(db, id); if (originalPath) { try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {} } router.back(); }, }, ], ); } // instanceUrl and token are in the dep array to avoid a stale-closure bug in // release builds: Hermes executes effects sooner and captures empty strings if // the deps are omitted. Guards on geojson/timeseries prevent double-fetching. 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)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [row?.id, instanceUrl, token]); 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 Delete {detail.sport ?? 'Activity'} {detail.title} {date} {/* Map */} {/* Stats grid */} {km && } {movingTime && } {elev != null && } {elevLoss != null && } {speed && } {hr && } {power && } {/* Metric charts */} {/* Meta */} ); } // ── Map ─────────────────────────────────────────────────────────────────────── function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) { const [fullscreen, setFullscreen] = useState(false); if (loading) { return ( ); } if (!geojson) return null; const bounds = geoJsonBounds(geojson); const routeSource = ( ); const camera = bounds ? ( ) : null; return ( <> {/* Thumbnail — tap to expand */} setFullscreen(true)}> {camera} {routeSource} ⤢ tap to explore {/* Full-screen interactive map */} setFullscreen(false)}> {camera} {routeSource} setFullscreen(false)}> ); } // ── Metric charts ───────────────────────────────────────────────────────────── type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power'; const TAB_META: Record = { elevation: { label: 'Elevation', unit: 'm', color: '#00c8ff', decimals: 0 }, speed: { label: 'Speed', unit: 'km/h', color: '#ff6b35', decimals: 1 }, hr: { label: 'HR', unit: 'bpm', color: '#f87171', decimals: 0 }, cadence: { label: 'Cadence', unit: 'rpm', color: '#a78bfa', decimals: 0 }, power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 }, }; function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) { const [activeTab, setActiveTab] = useState('elevation'); if (loading) { return ( ); } if (!timeseries) return null; const seriesMap: Record = { elevation: timeseries.elevation_m, speed: timeseries.speed_kmh, hr: timeseries.hr_bpm, cadence: timeseries.cadence_rpm, power: timeseries.power_w, }; const available = (Object.keys(TAB_META) as TabKey[]).filter( k => seriesMap[k]?.some(v => v != null) ); if (!available.length) return null; const tab = available.includes(activeTab) ? activeTab : available[0]; const { color, unit, decimals } = TAB_META[tab]; const raw = seriesMap[tab]!; return ( {/* Tab row */} {available.map(k => ( setActiveTab(k)} > {TAB_META[k].label} ))} {/* Chart */} ); } function MetricChart({ times, values, color, unit, decimals, }: { times: number[]; values: (number | null)[]; color: string; unit: string; decimals: number; }) { const W = 340; const H = 100; const PAD = 4; // Downsample to ≤300 points const step = Math.max(1, Math.floor(values.length / 300)); const ts = times.filter((_, i) => i % step === 0); const vs = values.filter((_, i) => i % step === 0).map(v => v ?? 0); const minV = Math.min(...vs); const maxV = Math.max(...vs); const range = maxV - minV || 1; const maxT = ts[ts.length - 1] || 1; const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2); const y = (v: number) => PAD + (1 - (v - minV) / range) * (H - PAD * 2); const pts = ts.map((t, i) => `${x(t).toFixed(1)},${y(vs[i]).toFixed(1)}`); const linePath = `M ${pts.join(' L ')}`; const areaPath = `M ${x(ts[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`; const gradId = `grad-${color.replace('#', '')}`; const fmt = (v: number) => decimals === 0 ? String(Math.round(v)) : v.toFixed(decimals); return ( <> {fmt(maxV)} {unit} {fmt(minV)} {unit} ); } // ── 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 ( {value} {unit ? {unit} : null} {label} ); } function MetaRow({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } // ── 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 }, topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 }, backButton: { paddingHorizontal: 16 }, backText: { fontSize: 15 }, deleteButton: { paddingHorizontal: 16 }, deleteText: { color: '#f87171', 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 }, mapExpandHint: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 4 }, mapExpandText: { color: '#a1a1aa', fontSize: 11 }, 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 }, 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' }, chartTab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' }, chartTabText: { color: '#52525b', fontSize: 11, fontWeight: '600' }, chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2, marginHorizontal: 12, marginTop: 10 }, 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 }, });