feat: activity detail screen with map, stats, and inline editing

Tap any saved recording to open ActivityDetailScreen:
- Full-screen MapLibre map with track fitted to bounds (LngLatBounds padding)
- Stats panel: duration, distance, avg speed, elevation gain (computed
  from track points), avg HR/power/cadence, point count
- 'Edit' button in header opens a pageSheet modal with title TextInput,
  sport grid, and subtype pills — same controls as PostRecordingScreen
- updateRecording() added to db.ts; edits update header title and sport
  summary without navigating away

SavedRecordingsScreen: tapping a card in normal mode navigates to detail;
tapping in selection mode still toggles the checkbox.
This commit is contained in:
Davide Scaini
2026-06-04 00:13:53 +02:00
parent 41a2435cc2
commit dd4533efd2
5 changed files with 329 additions and 10 deletions
+9 -7
View File
@@ -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() {
}}
>
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
<Stack.Screen name="PostRecording" component={PostRecordingScreen} options={{ title: 'Save Recording', presentation: 'modal' }} />
<Stack.Screen name="SensorPairing" component={SensorPairingScreen} options={{ title: 'Sensors', presentation: 'modal' }} />
<Stack.Screen name="PostRecording" component={PostRecordingScreen} options={{ title: 'Save Recording', presentation: 'modal' }} />
<Stack.Screen name="SensorPairing" component={SensorPairingScreen} options={{ title: 'Sensors', presentation: 'modal' }} />
<Stack.Screen name="ActivityDetail" component={ActivityDetailScreen} options={{ title: '' }} />
</Stack.Navigator>
</NavigationContainer>
);
+306
View File
@@ -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<RootStackParamList, 'ActivityDetail'>;
// ── 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<Route>();
const [rec, setRec] = useState<SavedRecording>(route.params.recording);
const [points, setPoints] = useState<TrackPoint[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
// Edit modal state
const [editVisible, setEditVisible] = useState(false);
const [editTitle, setEditTitle] = useState(rec.title);
const [editSport, setEditSport] = useState<Sport>(rec.sport as Sport);
const [editSubSport, setEditSubSport] = useState<SubSport | null>(rec.subSport as SubSport | null);
const [saving, setSaving] = useState(false);
// Header "Edit" button
useLayoutEffect(() => {
nav.setOptions({
headerTitle: rec.title,
headerRight: () => (
<TouchableOpacity onPress={() => setEditVisible(true)} style={{ marginRight: 16 }}>
<Text style={{ color: accent, fontSize: 15, fontWeight: '600' }}>Edit</Text>
</TouchableOpacity>
),
});
}, [nav, rec.title, accent]);
useEffect(() => {
parseGpxFile(rec.filePath)
.then(setPoints)
.catch((e) => setLoadError(e?.message ?? 'Failed to load track'));
}, [rec.filePath]);
const trackLineStyle = useMemo<LineLayerStyle>(() => ({
lineColor: accent, lineWidth: 3, lineJoin: 'round', lineCap: 'round',
}), [accent]);
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString> | 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 <View style={styles.center}><Text style={styles.errorText}>{loadError}</Text></View>;
}
return (
<View style={styles.container}>
{/* Map */}
<View style={styles.mapArea}>
{points === null
? <View style={styles.center}><ActivityIndicator color={accent} /></View>
: (
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
{bounds && (
<Camera
initialViewState={{ bounds, padding: { top: 32, bottom: 32, left: 32, right: 32 } }}
/>
)}
{trackGeoJSON && (
<GeoJSONSource id="track" data={trackGeoJSON}>
<Layer id="track-line" type="line" style={trackLineStyle} />
</GeoJSONSource>
)}
</Map>
)}
</View>
{/* Stats */}
<ScrollView style={styles.statsScroll} contentContainerStyle={styles.statsContent}>
<Text style={styles.sportSummary}>{sportSummary}</Text>
<Text style={styles.date}>{new Date(rec.date).toLocaleString()}</Text>
<View style={styles.grid}>
<StatBox label="Duration" value={formatDuration(rec.durationSeconds)} />
<StatBox label="Distance" value={`${(rec.distanceMeters / 1000).toFixed(2)} km`} />
<StatBox label="Avg Speed" value={`${avgSpeedKph.toFixed(1)} km/h`} />
<StatBox label="Elevation" value={stats ? `+${stats.elevationGainM.toFixed(0)} m` : '…'} />
<StatBox label="Avg HR" value={stats?.avgHr != null ? `${stats.avgHr} bpm` : '—'} />
<StatBox label="Avg Power" value={stats?.avgPower != null ? `${stats.avgPower} W` : '—'} />
<StatBox label="Avg Cadence" value={stats?.avgCadence != null ? `${stats.avgCadence} rpm` : '—'} />
<StatBox label="Points" value={points != null ? `${points.length}` : '…'} />
</View>
</ScrollView>
{/* ── Edit modal ── */}
<Modal visible={editVisible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleCancelEdit}>
<View style={styles.editContainer}>
<View style={styles.editHeader}>
<TouchableOpacity onPress={handleCancelEdit}>
<Text style={styles.editCancel}>Cancel</Text>
</TouchableOpacity>
<Text style={styles.editHeading}>Edit activity</Text>
<TouchableOpacity onPress={handleSave} disabled={saving}>
<Text style={[styles.editSave, { color: accent }, saving && { opacity: 0.5 }]}>
{saving ? 'Saving…' : 'Save'}
</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.editContent}>
<Text style={styles.editSectionLabel}>Title</Text>
<TextInput
style={styles.editInput}
value={editTitle}
onChangeText={setEditTitle}
placeholder="Activity title"
placeholderTextColor={colors.placeholder}
autoCorrect={false}
/>
<Text style={styles.editSectionLabel}>Activity type</Text>
<View style={styles.sportGrid}>
{ALL_SPORTS.map((s) => {
const active = editSport === s;
return (
<TouchableOpacity
key={s}
style={[styles.sportPill, active && { borderColor: accent, backgroundColor: accentDim }]}
onPress={() => handleEditSport(s)}
>
<Text style={styles.sportIcon}>{SPORT_ICONS[s]}</Text>
<Text style={[styles.sportLabel, active && { color: accent }]}>{SPORT_LABELS[s]}</Text>
</TouchableOpacity>
);
})}
</View>
{editSubtypes.length > 0 && (
<>
<Text style={styles.editSectionLabel}>Subtype</Text>
<View style={styles.subRow}>
{editSubtypes.map((sub) => {
const active = editSubSport === sub;
return (
<TouchableOpacity
key={sub}
style={[styles.subPill, active && { borderColor: accent, backgroundColor: accentDim }]}
onPress={() => setEditSubSport(active ? null : sub)}
>
<Text style={[styles.subLabel, active && { color: accent }]}>{SUB_SPORT_LABELS[sub]}</Text>
</TouchableOpacity>
);
})}
</View>
</>
)}
</ScrollView>
</View>
</Modal>
</View>
);
}
function StatBox({ label, value }: { label: string; value: string }) {
return (
<View style={styles.statBox}>
<Text style={styles.statLabel}>{label}</Text>
<Text style={styles.statValue}>{value}</Text>
</View>
);
}
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' },
});
+5 -3
View File
@@ -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<NativeStackNavigationProp<RootStackParamList>>();
const { accent, accentDim } = useTheme();
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
const [loading, setLoading] = useState(true);
@@ -200,8 +202,8 @@ export function SavedRecordingsScreen() {
const isSelected = selectedIds.has(item.id);
return (
<TouchableOpacity
activeOpacity={selectionMode ? 0.7 : 1}
onPress={() => 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 }]}
>
<View style={styles.cardInner}>
+8
View File
@@ -52,6 +52,14 @@ export async function listRecordings(): Promise<SavedRecording[]> {
}));
}
export async function updateRecording(id: string, fields: { title: string; sport: string; subSport: string | null }): Promise<void> {
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<void> {
const d = await getDb();
await d.runAsync('DELETE FROM recordings WHERE id = ?', [id]);
+1
View File
@@ -43,6 +43,7 @@ export type RootStackParamList = {
Tabs: undefined;
PostRecording: undefined;
SensorPairing: undefined;
ActivityDetail: { recording: SavedRecording };
};
export type TabParamList = {