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:
@@ -1,20 +1,54 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useLayoutEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
import {
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
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 * as Sharing from 'expo-sharing';
|
||||||
import { listRecordings, deleteRecording } from '../services/db';
|
import { listRecordings, deleteRecording, insertRecording } from '../services/db';
|
||||||
import { uploadGpx } from '../services/upload';
|
import { uploadGpx } from '../services/upload';
|
||||||
|
import { saveGpx, parseGpxFile } from '../services/gpx';
|
||||||
import { SavedRecording } from '../types';
|
import { SavedRecording } from '../types';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { randomUUID } from 'expo-crypto';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
|
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
|
||||||
|
|
||||||
export function SavedRecordingsScreen() {
|
export function SavedRecordingsScreen() {
|
||||||
const { accent } = useTheme();
|
const nav = useNavigation();
|
||||||
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
const { accent, accentDim } = useTheme();
|
||||||
const [loading, setLoading] = useState(true);
|
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
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) {
|
async function handleShare(rec: SavedRecording) {
|
||||||
await Sharing.shareAsync(rec.filePath, { mimeType: 'application/gpx+xml', dialogTitle: rec.title });
|
await Sharing.shareAsync(rec.filePath, { mimeType: 'application/gpx+xml', dialogTitle: rec.title });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(rec: SavedRecording) {
|
async function handleUpload(rec: SavedRecording) {
|
||||||
const instanceUrl = await AsyncStorage.getItem('instanceUrl');
|
const [instanceUrl, apiToken] = await Promise.all([
|
||||||
const apiToken = await AsyncStorage.getItem('apiToken');
|
AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('apiToken'),
|
||||||
if (!instanceUrl || !apiToken) {
|
]);
|
||||||
Alert.alert('Not connected', 'Log in to your bincio instance in Settings first.');
|
if (!instanceUrl || !apiToken) { Alert.alert('Not connected', 'Log in in Settings → Sync first.'); return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(rec.id);
|
|
||||||
const result = await uploadGpx(rec.filePath, instanceUrl, apiToken);
|
const result = await uploadGpx(rec.filePath, instanceUrl, apiToken);
|
||||||
setUploading(null);
|
if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded.`);
|
||||||
if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded successfully.`);
|
|
||||||
else Alert.alert('Upload failed', result.error);
|
else Alert.alert('Upload failed', result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(rec: SavedRecording) {
|
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: 'Cancel', style: 'cancel' },
|
||||||
{
|
{ text: 'Delete', style: 'destructive', onPress: async () => {
|
||||||
text: 'Delete',
|
await deleteRecording(rec.id);
|
||||||
style: 'destructive',
|
setRecordings((prev) => prev.filter((r) => r.id !== rec.id));
|
||||||
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 formatDuration = (secs: number) => {
|
||||||
const h = Math.floor(secs / 3600);
|
const h = Math.floor(secs / 3600);
|
||||||
const m = Math.floor((secs % 3600) / 60);
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
@@ -68,51 +196,143 @@ export function SavedRecordingsScreen() {
|
|||||||
data={recordings}
|
data={recordings}
|
||||||
keyExtractor={(r) => r.id}
|
keyExtractor={(r) => r.id}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => {
|
||||||
<View style={styles.card}>
|
const isSelected = selectedIds.has(item.id);
|
||||||
<View style={styles.cardMeta}>
|
return (
|
||||||
<View style={styles.cardTitleRow}>
|
<TouchableOpacity
|
||||||
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
|
activeOpacity={selectionMode ? 0.7 : 1}
|
||||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
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>
|
</View>
|
||||||
<Text style={styles.cardSub}>
|
|
||||||
{SPORT_LABELS[item.sport as Sport] ?? item.sport}
|
{/* Per-card actions (normal mode only) */}
|
||||||
{item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''}
|
{!selectionMode && (
|
||||||
{' · '}{new Date(item.date).toLocaleDateString()}
|
<View style={styles.cardActions}>
|
||||||
{' · '}{formatDuration(item.durationSeconds)}
|
<TouchableOpacity onPress={() => handleShare(item)}>
|
||||||
{' · '}{(item.distanceMeters / 1000).toFixed(2)} km
|
<Text style={[styles.action, { color: accent }]}>Export</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</View>
|
<TouchableOpacity onPress={() => handleUpload(item)}>
|
||||||
<View style={styles.cardActions}>
|
<Text style={[styles.action, { color: accent }]}>Upload</Text>
|
||||||
<TouchableOpacity onPress={() => handleShare(item)}>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.action, { color: accent }]}>Export</Text>
|
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||||
</TouchableOpacity>
|
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
||||||
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
|
</View>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
);
|
||||||
</TouchableOpacity>
|
}}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
ListEmptyComponent={<Text style={styles.empty}>No recordings yet.</Text>}
|
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>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: colors.bg },
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
||||||
list: { padding: 16, gap: 10 },
|
list: { padding: 16, gap: 10, paddingBottom: 24 },
|
||||||
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 },
|
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 14 },
|
||||||
cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
cardInner: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
cardIcon: { fontSize: 18 },
|
checkbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, borderColor: colors.borderStrong, alignItems: 'center', justifyContent: 'center' },
|
||||||
cardMeta: { marginBottom: 12 },
|
checkmark: { color: '#fff', fontSize: 13, fontWeight: '700' },
|
||||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
cardMeta: { flex: 1 },
|
||||||
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||||
cardActions:{ flexDirection: 'row', gap: 20 },
|
cardIcon: { fontSize: 17 },
|
||||||
action: { fontWeight: '600', fontSize: 14 },
|
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
|
||||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
|
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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ function buildExtensions(pt: TrackPoint): string {
|
|||||||
</extensions>`;
|
</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 {
|
function escapeXml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user