e08b024d15
In Hermes release builds, useEffect captures closure values from the first render. If instanceUrl or token were empty at that moment (before SQLite reads complete), no fetch ran and map/graphs never loaded. Adding them to the dependency array ensures the effect re-runs once the values are available; guards on existing geojson/timeseries state prevent double-fetching. Also add @maplibre/maplibre-react-native to the Expo plugins array so that expo prebuild applies the library's required Gradle property configuration to the Android project.
400 lines
18 KiB
TypeScript
400 lines
18 KiB
TypeScript
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { useEffect, useState } from 'react';
|
|
import { ActivityIndicator, Modal, 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)[];
|
|
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 row = useActivity(id);
|
|
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
|
const token = useSetting('api_token') ?? '';
|
|
|
|
const [geojson, setGeojson] = useState<object | null>(null);
|
|
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
|
|
const [loadingMap, setLoadingMap] = useState(false);
|
|
const [loadingChart, setLoadingChart] = useState(false);
|
|
|
|
// 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 (
|
|
<View style={styles.center}>
|
|
<Text style={styles.notFound}>Activity not found</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<Text style={styles.backText}>← Back</Text>
|
|
</Pressable>
|
|
|
|
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
|
<Text style={styles.title}>{detail.title}</Text>
|
|
<Text style={styles.date}>{date}</Text>
|
|
|
|
{/* Map */}
|
|
<RouteMap geojson={geojson} loading={loadingMap} />
|
|
|
|
{/* Stats grid */}
|
|
<View style={styles.grid}>
|
|
{km && <StatCell label="Distance" value={km} unit="km" />}
|
|
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
|
{elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
|
|
{elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
|
|
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
|
|
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
|
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
|
</View>
|
|
|
|
{/* Metric charts */}
|
|
<MetricCharts timeseries={timeseries} loading={loadingChart} />
|
|
|
|
{/* Meta */}
|
|
<View style={styles.meta}>
|
|
<MetaRow label="Source" value={detail.source ?? '—'} />
|
|
<MetaRow label="Device" value={detail.device ?? '—'} />
|
|
<MetaRow label="Origin" value={row.origin} />
|
|
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
|
|
</View>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
// ── Map ───────────────────────────────────────────────────────────────────────
|
|
|
|
function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) {
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.mapPlaceholder}>
|
|
<ActivityIndicator color="#60a5fa" />
|
|
</View>
|
|
);
|
|
}
|
|
if (!geojson) return null;
|
|
|
|
const bounds = geoJsonBounds(geojson);
|
|
const routeSource = (
|
|
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
|
|
<Layer
|
|
type="line"
|
|
id="route-line"
|
|
paint={{ 'line-color': '#60a5fa', 'line-width': 3 }}
|
|
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
|
|
/>
|
|
</GeoJSONSource>
|
|
);
|
|
const camera = bounds ? (
|
|
<Camera
|
|
initialViewState={{
|
|
bounds,
|
|
padding: { top: 24, bottom: 24, left: 24, right: 24 },
|
|
}}
|
|
/>
|
|
) : null;
|
|
|
|
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}
|
|
{routeSource}
|
|
</Map>
|
|
<View style={styles.mapExpandHint}>
|
|
<Text style={styles.mapExpandText}>⤢ tap to explore</Text>
|
|
</View>
|
|
</Pressable>
|
|
|
|
{/* Full-screen interactive map */}
|
|
<Modal visible={fullscreen} animationType="slide" onRequestClose={() => setFullscreen(false)}>
|
|
<View style={styles.fullscreenMap}>
|
|
<Map style={styles.map} mapStyle={MAP_STYLE}>
|
|
{camera}
|
|
{routeSource}
|
|
</Map>
|
|
<Pressable style={styles.closeButton} onPress={() => setFullscreen(false)}>
|
|
<Text style={styles.closeText}>✕</Text>
|
|
</Pressable>
|
|
</View>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Metric charts ─────────────────────────────────────────────────────────────
|
|
|
|
type TabKey = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
|
|
|
const TAB_META: Record<TabKey, { label: string; unit: string; color: string; decimals: number }> = {
|
|
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 }: { timeseries: Timeseries | null; loading: boolean }) {
|
|
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.chartPlaceholder}>
|
|
<ActivityIndicator color="#60a5fa" />
|
|
</View>
|
|
);
|
|
}
|
|
if (!timeseries) return null;
|
|
|
|
const seriesMap: Record<TabKey, (number | null)[] | null | undefined> = {
|
|
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 (
|
|
<View style={styles.chartContainer}>
|
|
{/* Tab row */}
|
|
<View style={styles.chartTabs}>
|
|
{available.map(k => (
|
|
<Pressable
|
|
key={k}
|
|
style={[styles.chartTab, tab === k && { borderBottomColor: TAB_META[k].color, borderBottomWidth: 2 }]}
|
|
onPress={() => setActiveTab(k)}
|
|
>
|
|
<Text style={[styles.chartTabText, tab === k && { color: TAB_META[k].color }]}>
|
|
{TAB_META[k].label}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
{/* Chart */}
|
|
<MetricChart key={tab} times={timeseries.t} values={raw} color={color} unit={unit} decimals={decimals} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<Text style={[styles.chartLabel, { color }]}>{fmt(maxV)} {unit}</Text>
|
|
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
|
|
<Defs>
|
|
<LinearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
|
<Stop offset="0" stopColor={color} stopOpacity="0.35" />
|
|
<Stop offset="1" stopColor={color} stopOpacity="0.02" />
|
|
</LinearGradient>
|
|
</Defs>
|
|
<Path d={areaPath} fill={`url(#${gradId})`} />
|
|
<Path d={linePath} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
|
</Svg>
|
|
<Text style={[styles.chartLabel, { color: '#3f3f46', marginBottom: 10 }]}>{fmt(minV)} {unit}</Text>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── 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<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') { 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 (
|
|
<View style={styles.statCell}>
|
|
<View style={styles.statValueRow}>
|
|
<Text style={styles.statValue}>{value}</Text>
|
|
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
|
|
</View>
|
|
<Text style={styles.statLabel}>{label}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{label}</Text>
|
|
<Text style={styles.metaValue}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ── 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 },
|
|
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 },
|
|
});
|