From 41a2435cc2bed889253e3562c3e9d6133f3189f6 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Thu, 4 Jun 2026 00:08:06 +0200 Subject: [PATCH] feat: bulk operations in Saved tab (export, upload, delete, merge) Select button in header enters selection mode. Cards show a checkbox and become tappable. Header updates to show N selected + Cancel. Bulk actions (bottom bar): - Export: sequential Sharing.shareAsync for each selected file - Upload: sequential upload with N/total progress text - Delete: confirm then delete all selected at once - Merge: parses each GPX file, combines track points sorted by time, prompts for title via Modal, saves as new recording parseGpxFile() added to gpx.ts: reads file via expo-file-system, extracts trkpt elements including hr/power/cad extensions via regex. --- src/screens/SavedRecordingsScreen.tsx | 350 +++++++++++++++++++++----- src/services/gpx.ts | 21 ++ 2 files changed, 306 insertions(+), 65 deletions(-) diff --git a/src/screens/SavedRecordingsScreen.tsx b/src/screens/SavedRecordingsScreen.tsx index 4a9d53a..2d92615 100644 --- a/src/screens/SavedRecordingsScreen.tsx +++ b/src/screens/SavedRecordingsScreen.tsx @@ -1,20 +1,54 @@ -import React, { useCallback, useState } from 'react'; -import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator } from 'react-native'; -import { useFocusEffect } from '@react-navigation/native'; +import React, { useCallback, useLayoutEffect, useState } from 'react'; +import { + View, Text, StyleSheet, FlatList, TouchableOpacity, + Alert, ActivityIndicator, Modal, TextInput, Pressable, +} from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import * as Sharing from 'expo-sharing'; -import { listRecordings, deleteRecording } from '../services/db'; +import { listRecordings, deleteRecording, insertRecording } from '../services/db'; import { uploadGpx } from '../services/upload'; +import { saveGpx, parseGpxFile } from '../services/gpx'; import { SavedRecording } from '../types'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { randomUUID } from 'expo-crypto'; import { colors } from '../theme'; import { useTheme } from '../ThemeContext'; import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports'; export function SavedRecordingsScreen() { - const { accent } = useTheme(); - const [recordings, setRecordings] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(null); + const nav = useNavigation(); + const { accent, accentDim } = useTheme(); + const [recordings, setRecordings] = useState([]); + const [loading, setLoading] = useState(true); + const [selectionMode, setSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [busyMsg, setBusyMsg] = useState(null); + const [mergeVisible, setMergeVisible] = useState(false); + const [mergeTitle, setMergeTitle] = useState(''); + + // ── Header button ──────────────────────────────────────────────────────── + + useLayoutEffect(() => { + nav.setOptions( + selectionMode + ? { + headerTitle: `${selectedIds.size} selected`, + headerRight: () => ( + + Cancel + + ), + } + : { + headerTitle: 'Saved', + headerRight: () => ( + + Select + + ), + }, + ); + }, [nav, selectionMode, selectedIds.size, accent]); useFocusEffect( useCallback(() => { @@ -22,38 +56,132 @@ export function SavedRecordingsScreen() { }, []), ); + // ── Selection helpers ──────────────────────────────────────────────────── + + function enterSelection() { setSelectionMode(true); setSelectedIds(new Set()); } + function exitSelection() { setSelectionMode(false); setSelectedIds(new Set()); } + + function toggleId(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + const selected = recordings.filter((r) => selectedIds.has(r.id)); + + // ── Single-item actions (normal mode) ──────────────────────────────────── + async function handleShare(rec: SavedRecording) { await Sharing.shareAsync(rec.filePath, { mimeType: 'application/gpx+xml', dialogTitle: rec.title }); } async function handleUpload(rec: SavedRecording) { - const instanceUrl = await AsyncStorage.getItem('instanceUrl'); - const apiToken = await AsyncStorage.getItem('apiToken'); - if (!instanceUrl || !apiToken) { - Alert.alert('Not connected', 'Log in to your bincio instance in Settings first.'); - return; - } - setUploading(rec.id); + const [instanceUrl, apiToken] = await Promise.all([ + AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('apiToken'), + ]); + if (!instanceUrl || !apiToken) { Alert.alert('Not connected', 'Log in in Settings → Sync first.'); return; } const result = await uploadGpx(rec.filePath, instanceUrl, apiToken); - setUploading(null); - if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded successfully.`); + if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded.`); else Alert.alert('Upload failed', result.error); } function handleDelete(rec: SavedRecording) { - Alert.alert('Delete recording?', `"${rec.title}" will be removed.`, [ + Alert.alert('Delete?', `"${rec.title}" will be removed.`, [ { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - await deleteRecording(rec.id); - setRecordings((prev) => prev.filter((r) => r.id !== rec.id)); - }, - }, + { text: 'Delete', style: 'destructive', onPress: async () => { + await deleteRecording(rec.id); + setRecordings((prev) => prev.filter((r) => r.id !== rec.id)); + }}, ]); } + // ── Bulk actions ───────────────────────────────────────────────────────── + + async function handleBulkExport() { + for (let i = 0; i < selected.length; i++) { + setBusyMsg(`Exporting ${i + 1}/${selected.length}…`); + await Sharing.shareAsync(selected[i].filePath, { mimeType: 'application/gpx+xml', dialogTitle: selected[i].title }); + } + setBusyMsg(null); + } + + async function handleBulkUpload() { + const [instanceUrl, apiToken] = await Promise.all([ + AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('apiToken'), + ]); + if (!instanceUrl || !apiToken) { Alert.alert('Not connected', 'Log in in Settings → Sync first.'); return; } + let ok = 0, fail = 0; + for (let i = 0; i < selected.length; i++) { + setBusyMsg(`Uploading ${i + 1}/${selected.length}…`); + const result = await uploadGpx(selected[i].filePath, instanceUrl, apiToken); + result.ok ? ok++ : fail++; + } + setBusyMsg(null); + Alert.alert('Upload complete', `${ok} uploaded${fail ? `, ${fail} failed` : ''}.`); + } + + function handleBulkDelete() { + Alert.alert( + `Delete ${selected.length} recording${selected.length !== 1 ? 's' : ''}?`, + 'This cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: async () => { + setBusyMsg('Deleting…'); + for (const rec of selected) await deleteRecording(rec.id); + setRecordings((prev) => prev.filter((r) => !selectedIds.has(r.id))); + setBusyMsg(null); + exitSelection(); + }}, + ], + ); + } + + function openMergeModal() { + const names = selected.map((r) => r.title).join(' + '); + setMergeTitle(names.length > 60 ? names.slice(0, 60) + '…' : names); + setMergeVisible(true); + } + + async function handleMergeConfirm() { + if (!mergeTitle.trim()) { Alert.alert('Title required'); return; } + setMergeVisible(false); + setBusyMsg('Merging…'); + try { + // Parse all GPX files and combine track points sorted by time + const allPoints = ( + await Promise.all(selected.map((r) => parseGpxFile(r.filePath))) + ).flat().sort((a, b) => a.time.getTime() - b.time.getTime()); + + const sport = selected[0].sport; + const subSport = selected[0].subSport; + const filePath = saveGpx(allPoints, mergeTitle.trim()); + const merged: SavedRecording = { + id: randomUUID(), + title: mergeTitle.trim(), + date: allPoints[0]?.time.toISOString() ?? new Date().toISOString(), + durationSeconds: selected.reduce((s, r) => s + r.durationSeconds, 0), + distanceMeters: selected.reduce((s, r) => s + r.distanceMeters, 0), + filePath, + sport, + subSport, + }; + await insertRecording(merged); + const updated = await listRecordings(); + setRecordings(updated); + setBusyMsg(null); + exitSelection(); + Alert.alert('Merged', `"${merged.title}" saved with ${allPoints.length} track points.`); + } catch (e: any) { + setBusyMsg(null); + Alert.alert('Merge failed', e?.message ?? 'Unknown error'); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + const formatDuration = (secs: number) => { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); @@ -68,51 +196,143 @@ export function SavedRecordingsScreen() { data={recordings} keyExtractor={(r) => r.id} contentContainerStyle={styles.list} - renderItem={({ item }) => ( - - - - {SPORT_ICONS[item.sport as Sport] ?? '⚡'} - {item.title} + renderItem={({ item }) => { + const isSelected = selectedIds.has(item.id); + return ( + selectionMode && toggleId(item.id)} + style={[styles.card, isSelected && { borderColor: accent, backgroundColor: accentDim }]} + > + + {/* Checkbox (selection mode only) */} + {selectionMode && ( + + {isSelected && } + + )} + + {/* Meta */} + + + {SPORT_ICONS[item.sport as Sport] ?? '⚡'} + {item.title} + + + {SPORT_LABELS[item.sport as Sport] ?? item.sport} + {item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''} + {' · '}{new Date(item.date).toLocaleDateString()} + {' · '}{formatDuration(item.durationSeconds)} + {' · '}{(item.distanceMeters / 1000).toFixed(2)} km + + - - {SPORT_LABELS[item.sport as Sport] ?? item.sport} - {item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''} - {' · '}{new Date(item.date).toLocaleDateString()} - {' · '}{formatDuration(item.durationSeconds)} - {' · '}{(item.distanceMeters / 1000).toFixed(2)} km - - - - handleShare(item)}> - Export - - handleUpload(item)} disabled={uploading === item.id}> - {uploading === item.id ? '…' : 'Upload'} - - handleDelete(item)}> - Delete - - - - )} + + {/* Per-card actions (normal mode only) */} + {!selectionMode && ( + + handleShare(item)}> + Export + + handleUpload(item)}> + Upload + + handleDelete(item)}> + Delete + + + )} + + ); + }} ListEmptyComponent={No recordings yet.} /> + + {/* Bulk action bar */} + {selectionMode && ( + + {busyMsg ? ( + + + {busyMsg} + + ) : ( + <> + + + + + + )} + + )} + + {/* Merge title modal */} + setMergeVisible(false)}> + setMergeVisible(false)}> + {}}> + Merge {selected.length} recordings + + + setMergeVisible(false)}> + Cancel + + + Merge + + + + + ); } +function BulkBtn({ label, onPress, disabled, accent }: { label: string; onPress: () => void; disabled: boolean; accent: string }) { + return ( + + {label} + + ); +} + const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bg }, - center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }, - list: { padding: 16, gap: 10 }, - card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 }, - cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }, - cardIcon: { fontSize: 18 }, - cardMeta: { marginBottom: 12 }, - cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' }, - cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 }, - cardActions:{ flexDirection: 'row', gap: 20 }, - action: { fontWeight: '600', fontSize: 14 }, - empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 }, + container: { flex: 1, backgroundColor: colors.bg }, + center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }, + list: { padding: 16, gap: 10, paddingBottom: 24 }, + card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 14 }, + cardInner: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + checkbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, borderColor: colors.borderStrong, alignItems: 'center', justifyContent: 'center' }, + checkmark: { color: '#fff', fontSize: 13, fontWeight: '700' }, + cardMeta: { flex: 1 }, + cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 }, + cardIcon: { fontSize: 17 }, + cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 }, + cardSub: { color: colors.textMuted, fontSize: 12 }, + cardActions: { flexDirection: 'row', gap: 20, marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: colors.border }, + action: { fontWeight: '600', fontSize: 14 }, + empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 }, + // Action bar + actionBar: { flexDirection: 'row', backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.border, paddingVertical: 12, paddingHorizontal: 8 }, + bulkBtn: { flex: 1, alignItems: 'center', paddingVertical: 8 }, + bulkBtnDisabled:{ opacity: 0.4 }, + bulkBtnText: { fontSize: 14, fontWeight: '600' }, + busyRow: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 }, + busyText: { fontSize: 14, fontWeight: '500' }, + // Merge modal + modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 32 }, + modalBox: { backgroundColor: colors.surface, borderRadius: 14, padding: 20, gap: 16 }, + modalHeading: { color: colors.text, fontSize: 17, fontWeight: '700' }, + modalInput: { backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 }, + modalButtons: { flexDirection: 'row', gap: 10 }, + modalBtn: { flex: 1, borderRadius: 8, padding: 12, alignItems: 'center', backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border }, + modalBtnText: { fontSize: 15, fontWeight: '600' }, }); diff --git a/src/services/gpx.ts b/src/services/gpx.ts index daad853..f81b048 100644 --- a/src/services/gpx.ts +++ b/src/services/gpx.ts @@ -55,6 +55,27 @@ function buildExtensions(pt: TrackPoint): string { `; } +export async function parseGpxFile(fileUri: string): Promise { + const content = await new File(fileUri).text(); + const points: TrackPoint[] = []; + const re = /([\s\S]*?)<\/trkpt>/g; + let m; + while ((m = re.exec(content)) !== null) { + const lat = parseFloat(m[1]); + const lon = parseFloat(m[2]); + const inner = m[3]; + const ele = parseFloat(inner.match(/([\d.-]+)<\/ele>/)?.[1] ?? '0'); + const timeStr = inner.match(/