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 + ? + : ( + + {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' }, +}); 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 = {