diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 3aaf05d..e8c5f16 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -4,11 +4,12 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Text } from 'react-native';
-import { RecordingScreen } from '../screens/RecordingScreen';
-import { PostRecordingScreen } from '../screens/PostRecordingScreen';
-import { SensorPairingScreen } from '../screens/SensorPairingScreen';
-import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
-import { SettingsScreen } from '../screens/SettingsScreen';
+import { RecordingScreen } from '../screens/RecordingScreen';
+import { PostRecordingScreen } from '../screens/PostRecordingScreen';
+import { SensorPairingScreen } from '../screens/SensorPairingScreen';
+import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
+import { SettingsScreen } from '../screens/SettingsScreen';
+import { ActivityDetailScreen } from '../screens/ActivityDetailScreen';
import { RootStackParamList, TabParamList } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
@@ -55,8 +56,9 @@ export function AppNavigator() {
}}
>
-
-
+
+
+
);
diff --git a/src/screens/ActivityDetailScreen.tsx b/src/screens/ActivityDetailScreen.tsx
new file mode 100644
index 0000000..4ccd0ce
--- /dev/null
+++ b/src/screens/ActivityDetailScreen.tsx
@@ -0,0 +1,306 @@
+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
+ ?
+ : (
+
+ )}
+
+
+ {/* 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' },
+});
diff --git a/src/screens/SavedRecordingsScreen.tsx b/src/screens/SavedRecordingsScreen.tsx
index 2d92615..90a8564 100644
--- a/src/screens/SavedRecordingsScreen.tsx
+++ b/src/screens/SavedRecordingsScreen.tsx
@@ -4,6 +4,8 @@ import {
Alert, ActivityIndicator, Modal, TextInput, Pressable,
} from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
+import { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import { RootStackParamList } from '../types';
import * as Sharing from 'expo-sharing';
import { listRecordings, deleteRecording, insertRecording } from '../services/db';
import { uploadGpx } from '../services/upload';
@@ -16,7 +18,7 @@ import { useTheme } from '../ThemeContext';
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
export function SavedRecordingsScreen() {
- const nav = useNavigation();
+ const nav = useNavigation>();
const { accent, accentDim } = useTheme();
const [recordings, setRecordings] = useState([]);
const [loading, setLoading] = useState(true);
@@ -200,8 +202,8 @@ export function SavedRecordingsScreen() {
const isSelected = selectedIds.has(item.id);
return (
selectionMode && toggleId(item.id)}
+ activeOpacity={0.7}
+ onPress={() => selectionMode ? toggleId(item.id) : nav.navigate('ActivityDetail', { recording: item })}
style={[styles.card, isSelected && { borderColor: accent, backgroundColor: accentDim }]}
>
diff --git a/src/services/db.ts b/src/services/db.ts
index 89fb341..54f37af 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -52,6 +52,14 @@ export async function listRecordings(): Promise {
}));
}
+export async function updateRecording(id: string, fields: { title: string; sport: string; subSport: string | null }): Promise {
+ const d = await getDb();
+ await d.runAsync(
+ 'UPDATE recordings SET title = ?, sport = ?, sub_sport = ? WHERE id = ?',
+ [fields.title, fields.sport, fields.subSport ?? null, id],
+ );
+}
+
export async function deleteRecording(id: string): Promise {
const d = await getDb();
await d.runAsync('DELETE FROM recordings WHERE id = ?', [id]);
diff --git a/src/types/index.ts b/src/types/index.ts
index b4affbd..968ff0c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -43,6 +43,7 @@ export type RootStackParamList = {
Tabs: undefined;
PostRecording: undefined;
SensorPairing: undefined;
+ ActivityDetail: { recording: SavedRecording };
};
export type TabParamList = {