import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, ScrollView, Modal, TextInput, TouchableOpacity, Pressable, Alert, } from 'react-native'; import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; import { Map, Camera, GeoJSONSource, Layer } from '@maplibre/maplibre-react-native'; import type { LineLayerStyle } from '@maplibre/maplibre-react-native'; import { parseGpxFile } from '../services/gpx'; import { updateRecording } from '../services/db'; import { TrackPoint, RootStackParamList, SavedRecording } from '../types'; import { ALL_SPORTS, SPORT_ICONS, SPORT_LABELS, SPORT_SUBTYPES, SUB_SPORT_LABELS, type Sport, type SubSport, } from '../sports'; import { colors } from '../theme'; import { useTheme } from '../ThemeContext'; const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; type Route = RouteProp; // ── Stats helpers ───────────────────────────────────────────────────────────── interface Stats { elevationGainM: number; avgHr: number | null; avgPower: number | null; avgCadence: number | null; } function computeStats(points: TrackPoint[]): Stats { let elevationGainM = 0; const hrVals: number[] = [], powerVals: number[] = [], cadVals: number[] = []; for (let i = 1; i < points.length; i++) { const gain = points[i].ele - points[i - 1].ele; if (gain > 0) elevationGainM += gain; } for (const p of points) { if (p.hr != null) hrVals.push(p.hr); if (p.power != null) powerVals.push(p.power); if (p.cad != null) cadVals.push(p.cad); } const avg = (a: number[]) => a.length ? Math.round(a.reduce((s, v) => s + v, 0) / a.length) : null; return { elevationGainM, avgHr: avg(hrVals), avgPower: avg(powerVals), avgCadence: avg(cadVals) }; } function formatDuration(secs: number) { const h = Math.floor(secs / 3600).toString().padStart(2, '0'); const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '00'); const s = (secs % 60).toString().padStart(2, '0'); return `${h}:${m}:${s}`; } // ── Screen ──────────────────────────────────────────────────────────────────── export function ActivityDetailScreen() { const nav = useNavigation(); const { accent, accentDim } = useTheme(); const route = useRoute(); const [rec, setRec] = useState(route.params.recording); const [points, setPoints] = useState(null); const [loadError, setLoadError] = useState(null); // Edit modal state const [editVisible, setEditVisible] = useState(false); const [editTitle, setEditTitle] = useState(rec.title); const [editSport, setEditSport] = useState(rec.sport as Sport); const [editSubSport, setEditSubSport] = useState(rec.subSport as SubSport | null); const [saving, setSaving] = useState(false); // Header "Edit" button useLayoutEffect(() => { nav.setOptions({ headerTitle: rec.title, headerRight: () => ( setEditVisible(true)} style={{ marginRight: 16 }}> Edit ), }); }, [nav, rec.title, accent]); useEffect(() => { parseGpxFile(rec.filePath) .then(setPoints) .catch((e) => setLoadError(e?.message ?? 'Failed to load track')); }, [rec.filePath]); const trackLineStyle = useMemo(() => ({ lineColor: accent, lineWidth: 3, lineJoin: 'round', lineCap: 'round', }), [accent]); const trackGeoJSON = useMemo | null>(() => { if (!points || points.length < 2) return null; return { type: 'Feature', geometry: { type: 'LineString', coordinates: points.map((p) => [p.lon, p.lat]) }, properties: {}, }; }, [points]); const bounds = useMemo(() => { if (!points || points.length === 0) return null; const pad = 0.001; return [ Math.min(...points.map((p) => p.lon)) - pad, Math.min(...points.map((p) => p.lat)) - pad, Math.max(...points.map((p) => p.lon)) + pad, Math.max(...points.map((p) => p.lat)) + pad, ] as [number, number, number, number]; }, [points]); const stats = useMemo(() => points ? computeStats(points) : null, [points]); const avgSpeedKph = rec.durationSeconds > 0 ? (rec.distanceMeters / 1000) / (rec.durationSeconds / 3600) : 0; const sportSummary = [ SPORT_ICONS[rec.sport as Sport] ?? '⚡', SPORT_LABELS[rec.sport as Sport] ?? rec.sport, rec.subSport ? (SUB_SPORT_LABELS[rec.subSport as SubSport] ?? rec.subSport) : null, ].filter(Boolean).join(' · '); // ── Edit handlers ────────────────────────────────────────────────────────── function handleEditSport(s: Sport) { setEditSport(s); setEditSubSport(null); } async function handleSave() { if (!editTitle.trim()) { Alert.alert('Title required'); return; } setSaving(true); try { await updateRecording(rec.id, { title: editTitle.trim(), sport: editSport, subSport: editSubSport }); setRec((prev) => ({ ...prev, title: editTitle.trim(), sport: editSport, subSport: editSubSport })); setEditVisible(false); } catch (e: any) { Alert.alert('Save failed', e?.message ?? 'Unknown error'); } finally { setSaving(false); } } function handleCancelEdit() { // Reset edit fields to current rec values setEditTitle(rec.title); setEditSport(rec.sport as Sport); setEditSubSport(rec.subSport as SubSport | null); setEditVisible(false); } const editSubtypes = SPORT_SUBTYPES[editSport] ?? []; // ── Render ───────────────────────────────────────────────────────────────── if (loadError) { return {loadError}; } return ( {/* Map */} {points === null ? : ( {bounds && ( )} {trackGeoJSON && ( )} )} {/* Stats */} {sportSummary} {new Date(rec.date).toLocaleString()} {/* ── Edit modal ── */} Cancel Edit activity {saving ? 'Saving…' : 'Save'} Title Activity type {ALL_SPORTS.map((s) => { const active = editSport === s; return ( handleEditSport(s)} > {SPORT_ICONS[s]} {SPORT_LABELS[s]} ); })} {editSubtypes.length > 0 && ( <> Subtype {editSubtypes.map((sub) => { const active = editSubSport === sub; return ( setEditSubSport(active ? null : sub)} > {SUB_SPORT_LABELS[sub]} ); })} )} ); } function StatBox({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }, errorText: { color: colors.error, fontSize: 14 }, mapArea: { flex: 1 }, statsScroll: { maxHeight: 260, borderTopWidth: 1, borderTopColor: colors.border }, statsContent: { padding: 16, gap: 10 }, sportSummary: { color: colors.text, fontSize: 15, fontWeight: '600' }, date: { color: colors.textMuted, fontSize: 12 }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, statBox: { width: '47%', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 12 }, statLabel: { color: colors.textMuted, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }, statValue: { color: colors.text, fontSize: 18, fontWeight: '600', marginTop: 4 }, // Edit modal editContainer: { flex: 1, backgroundColor: colors.bg }, editHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 16, borderBottomWidth: 1, borderBottomColor: colors.border }, editHeading: { color: colors.text, fontSize: 16, fontWeight: '700' }, editCancel: { color: colors.textSub, fontSize: 15 }, editSave: { fontSize: 15, fontWeight: '700' }, editContent: { padding: 16, gap: 12 }, editSectionLabel: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8 }, editInput: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 10, padding: 14, fontSize: 16 }, sportGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, sportPill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 9, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong, backgroundColor: colors.surface }, sportIcon: { fontSize: 16 }, sportLabel: { color: colors.textSub, fontSize: 13, fontWeight: '500' }, subRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, subPill: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong, backgroundColor: colors.surface }, subLabel: { color: colors.textSub, fontSize: 13, fontWeight: '500' }, });