a796bf8cae
Diagnosed via on-device debug: build_timeseries produces 3271 points correctly, but the installed wheel's write_activity has a silent exception path that skips writing the timeseries file. The workaround calls build_timeseries directly and writes the file if missing. Also moves useTheme import to @/ThemeContext across all tab screens.
434 lines
19 KiB
TypeScript
434 lines
19 KiB
TypeScript
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<object | null>(null);
|
|
const [timeseries, setTimeseries] = useState<Timeseries | null>(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 (
|
|
<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}>
|
|
<View style={styles.topBar}>
|
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
|
<Text style={[styles.backText, { color: theme.accent }]}>← Back</Text>
|
|
</Pressable>
|
|
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
|
|
<Text style={styles.deleteText}>Delete</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
<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} accent={theme.accent} />
|
|
|
|
{/* 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} accent={theme.accent} />
|
|
|
|
{/* 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, accent }: { geojson: object | null; loading: boolean; accent: string }) {
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.mapPlaceholder}>
|
|
<ActivityIndicator color={accent} />
|
|
</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': accent, '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, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
|
|
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={styles.chartPlaceholder}>
|
|
<ActivityIndicator color={accent} />
|
|
</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 },
|
|
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 },
|
|
});
|