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.
This commit is contained in:
Davide Scaini
2026-06-04 00:08:06 +02:00
parent cb74135c6c
commit 41a2435cc2
2 changed files with 306 additions and 65 deletions
+285 -65
View File
@@ -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<SavedRecording[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState<string | null>(null);
const nav = useNavigation();
const { accent, accentDim } = useTheme();
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
const [loading, setLoading] = useState(true);
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [busyMsg, setBusyMsg] = useState<string | null>(null);
const [mergeVisible, setMergeVisible] = useState(false);
const [mergeTitle, setMergeTitle] = useState('');
// ── Header button ────────────────────────────────────────────────────────
useLayoutEffect(() => {
nav.setOptions(
selectionMode
? {
headerTitle: `${selectedIds.size} selected`,
headerRight: () => (
<TouchableOpacity onPress={exitSelection} style={{ marginRight: 16 }}>
<Text style={{ color: accent, fontSize: 15, fontWeight: '600' }}>Cancel</Text>
</TouchableOpacity>
),
}
: {
headerTitle: 'Saved',
headerRight: () => (
<TouchableOpacity onPress={enterSelection} style={{ marginRight: 16 }}>
<Text style={{ color: accent, fontSize: 15, fontWeight: '600' }}>Select</Text>
</TouchableOpacity>
),
},
);
}, [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 }) => (
<View style={styles.card}>
<View style={styles.cardMeta}>
<View style={styles.cardTitleRow}>
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
<Text style={styles.cardTitle}>{item.title}</Text>
renderItem={({ item }) => {
const isSelected = selectedIds.has(item.id);
return (
<TouchableOpacity
activeOpacity={selectionMode ? 0.7 : 1}
onPress={() => selectionMode && toggleId(item.id)}
style={[styles.card, isSelected && { borderColor: accent, backgroundColor: accentDim }]}
>
<View style={styles.cardInner}>
{/* Checkbox (selection mode only) */}
{selectionMode && (
<View style={[styles.checkbox, isSelected && { borderColor: accent, backgroundColor: accent }]}>
{isSelected && <Text style={styles.checkmark}></Text>}
</View>
)}
{/* Meta */}
<View style={styles.cardMeta}>
<View style={styles.cardTitleRow}>
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
<Text style={styles.cardTitle} numberOfLines={1}>{item.title}</Text>
</View>
<Text style={styles.cardSub}>
{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
</Text>
</View>
</View>
<Text style={styles.cardSub}>
{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
</Text>
</View>
<View style={styles.cardActions}>
<TouchableOpacity onPress={() => handleShare(item)}>
<Text style={[styles.action, { color: accent }]}>Export</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
<Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Per-card actions (normal mode only) */}
{!selectionMode && (
<View style={styles.cardActions}>
<TouchableOpacity onPress={() => handleShare(item)}>
<Text style={[styles.action, { color: accent }]}>Export</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleUpload(item)}>
<Text style={[styles.action, { color: accent }]}>Upload</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
</TouchableOpacity>
</View>
)}
</TouchableOpacity>
);
}}
ListEmptyComponent={<Text style={styles.empty}>No recordings yet.</Text>}
/>
{/* Bulk action bar */}
{selectionMode && (
<View style={styles.actionBar}>
{busyMsg ? (
<View style={styles.busyRow}>
<ActivityIndicator color={accent} />
<Text style={[styles.busyText, { color: accent }]}>{busyMsg}</Text>
</View>
) : (
<>
<BulkBtn label="Export" onPress={handleBulkExport} disabled={selected.length === 0} accent={accent} />
<BulkBtn label="Upload" onPress={handleBulkUpload} disabled={selected.length === 0} accent={accent} />
<BulkBtn label="Delete" onPress={handleBulkDelete} disabled={selected.length === 0} accent={colors.error} />
<BulkBtn label="Merge" onPress={openMergeModal} disabled={selected.length < 2} accent={accent} />
</>
)}
</View>
)}
{/* Merge title modal */}
<Modal visible={mergeVisible} transparent animationType="fade" onRequestClose={() => setMergeVisible(false)}>
<Pressable style={styles.modalOverlay} onPress={() => setMergeVisible(false)}>
<Pressable style={styles.modalBox} onPress={() => {}}>
<Text style={styles.modalHeading}>Merge {selected.length} recordings</Text>
<TextInput
style={styles.modalInput}
value={mergeTitle}
onChangeText={setMergeTitle}
placeholder="Merged activity title"
placeholderTextColor={colors.placeholder}
autoFocus
selectTextOnFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity style={styles.modalBtn} onPress={() => setMergeVisible(false)}>
<Text style={[styles.modalBtnText, { color: colors.textSub }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalBtn, { backgroundColor: accent }]} onPress={handleMergeConfirm}>
<Text style={[styles.modalBtnText, { color: '#fff' }]}>Merge</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
function BulkBtn({ label, onPress, disabled, accent }: { label: string; onPress: () => void; disabled: boolean; accent: string }) {
return (
<TouchableOpacity style={[styles.bulkBtn, disabled && styles.bulkBtnDisabled]} onPress={onPress} disabled={disabled}>
<Text style={[styles.bulkBtnText, { color: disabled ? colors.textMuted : accent }]}>{label}</Text>
</TouchableOpacity>
);
}
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' },
});
+21
View File
@@ -55,6 +55,27 @@ function buildExtensions(pt: TrackPoint): string {
</extensions>`;
}
export async function parseGpxFile(fileUri: string): Promise<TrackPoint[]> {
const content = await new File(fileUri).text();
const points: TrackPoint[] = [];
const re = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)">([\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(/<ele>([\d.-]+)<\/ele>/)?.[1] ?? '0');
const timeStr = inner.match(/<time>([^<]+)<\/time>/)?.[1] ?? '';
const hr = parseInt(inner.match(/<gpxtpx:hr>(\d+)<\/gpxtpx:hr>/)?.[1] ?? '') || undefined;
const power = parseInt(inner.match(/<gpxtpx:power>(\d+)<\/gpxtpx:power>/)?.[1] ?? '') || undefined;
const cad = parseInt(inner.match(/<gpxtpx:cad>(\d+)<\/gpxtpx:cad>/)?.[1] ?? '') || undefined;
if (!isNaN(lat) && !isNaN(lon)) {
points.push({ lat, lon, ele, time: new Date(timeStr), hr, power, cad });
}
}
return points;
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}