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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -43,6 +43,7 @@ export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
PostRecording: undefined;
|
||||
SensorPairing: undefined;
|
||||
ActivityDetail: { recording: SavedRecording };
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
|
||||
Reference in New Issue
Block a user