feat: add delete button for local activities (single and bulk)
- Detail screen: Delete button (top-right, red) with confirmation alert; deletes SQLite row and original file via expo-file-system - Feed screen: long-press card to enter select mode; checkbox + blue border on selected cards; bottom action bar with bulk Delete N button; header switches to show count + Cancel - db/queries: deleteActivity (returns original_path) and deleteActivities (bulk, returns all original paths)
This commit is contained in:
+133
-15
@@ -1,8 +1,9 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
import { useActivities, type ActivitySummary } from '@/db/queries';
|
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
|
||||||
import { syncFeed } from '@/db/sync';
|
import { syncFeed } from '@/db/sync';
|
||||||
|
|
||||||
export default function FeedScreen() {
|
export default function FeedScreen() {
|
||||||
@@ -10,6 +11,8 @@ export default function FeedScreen() {
|
|||||||
const activities = useActivities();
|
const activities = useActivities();
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const selecting = selected.size > 0;
|
||||||
|
|
||||||
const doSync = useCallback(async () => {
|
const doSync = useCallback(async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
@@ -32,16 +35,64 @@ export default function FeedScreen() {
|
|||||||
setTimeout(() => setSyncMsg(null), 3500);
|
setTimeout(() => setSyncMsg(null), 3500);
|
||||||
}, [db]);
|
}, [db]);
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSelect() {
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteSelected() {
|
||||||
|
const count = selected.size;
|
||||||
|
Alert.alert(
|
||||||
|
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
|
||||||
|
'These activities will be permanently removed from your device.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const ids = Array.from(selected);
|
||||||
|
const paths = await deleteActivities(db, ids);
|
||||||
|
setSelected(new Set());
|
||||||
|
for (const p of paths) {
|
||||||
|
if (p) {
|
||||||
|
try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<Text style={styles.header}>Feed</Text>
|
{selecting ? (
|
||||||
<Pressable
|
<>
|
||||||
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
|
<Text style={styles.header}>{selected.size} selected</Text>
|
||||||
onPress={syncing ? undefined : doSync}
|
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
|
||||||
>
|
<Text style={styles.cancelText}>Cancel</Text>
|
||||||
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
|
</Pressable>
|
||||||
</Pressable>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.header}>Feed</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
|
||||||
|
onPress={syncing ? undefined : doSync}
|
||||||
|
>
|
||||||
|
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{syncMsg && (
|
{syncMsg && (
|
||||||
@@ -60,7 +111,15 @@ export default function FeedScreen() {
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={activities}
|
data={activities}
|
||||||
keyExtractor={(a) => a.id}
|
keyExtractor={(a) => a.id}
|
||||||
renderItem={({ item }) => <ActivityCard activity={item} />}
|
renderItem={({ item }) => (
|
||||||
|
<ActivityCard
|
||||||
|
activity={item}
|
||||||
|
selecting={selecting}
|
||||||
|
checked={selected.has(item.id)}
|
||||||
|
onToggleSelect={() => toggleSelect(item.id)}
|
||||||
|
onLongPress={() => toggleSelect(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -71,11 +130,31 @@ export default function FeedScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selecting && (
|
||||||
|
<View style={styles.actionBar}>
|
||||||
|
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
|
||||||
|
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
function ActivityCard({
|
||||||
|
activity,
|
||||||
|
selecting,
|
||||||
|
checked,
|
||||||
|
onToggleSelect,
|
||||||
|
onLongPress,
|
||||||
|
}: {
|
||||||
|
activity: ActivitySummary;
|
||||||
|
selecting: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
onToggleSelect: () => void;
|
||||||
|
onLongPress: () => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
||||||
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
||||||
@@ -83,13 +162,29 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
|||||||
day: 'numeric', month: 'short', year: 'numeric',
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handlePress() {
|
||||||
|
if (selecting) {
|
||||||
|
onToggleSelect();
|
||||||
|
} else {
|
||||||
|
router.push(`/activity/${activity.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.card}
|
style={[styles.card, checked && styles.cardSelected]}
|
||||||
onPress={() => router.push(`/activity/${activity.id}`)}
|
onPress={handlePress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
>
|
>
|
||||||
<View style={styles.cardTop}>
|
<View style={styles.cardTop}>
|
||||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
<View style={styles.cardLeft}>
|
||||||
|
{selecting && (
|
||||||
|
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
|
||||||
|
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||||
|
</View>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
<Text style={styles.cardDate}>{date}</Text>
|
<Text style={styles.cardDate}>{date}</Text>
|
||||||
{activity.origin === 'remote'
|
{activity.origin === 'remote'
|
||||||
@@ -136,16 +231,23 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
syncButtonDisabled: { opacity: 0.5 },
|
syncButtonDisabled: { opacity: 0.5 },
|
||||||
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
|
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: '#27272a', borderRadius: 8,
|
||||||
|
paddingHorizontal: 14, paddingVertical: 7,
|
||||||
|
},
|
||||||
|
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
||||||
syncMsg: {
|
syncMsg: {
|
||||||
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
|
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
|
||||||
paddingHorizontal: 16, paddingBottom: 8,
|
paddingHorizontal: 16, paddingBottom: 8,
|
||||||
},
|
},
|
||||||
list: { padding: 16, gap: 12 },
|
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#18181b', borderRadius: 12,
|
backgroundColor: '#18181b', borderRadius: 12,
|
||||||
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
||||||
},
|
},
|
||||||
|
cardSelected: { borderColor: '#60a5fa' },
|
||||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||||
|
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
sportIcon: { fontSize: 20 },
|
sportIcon: { fontSize: 20 },
|
||||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
cardDate: { color: '#71717a', fontSize: 12 },
|
cardDate: { color: '#71717a', fontSize: 12 },
|
||||||
@@ -162,10 +264,26 @@ const styles = StyleSheet.create({
|
|||||||
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
||||||
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
||||||
statLabel: { color: '#71717a', fontSize: 12 },
|
statLabel: { color: '#71717a', fontSize: 12 },
|
||||||
|
checkbox: {
|
||||||
|
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
|
||||||
|
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
|
||||||
|
},
|
||||||
|
checkboxChecked: { backgroundColor: '#60a5fa', borderColor: '#60a5fa' },
|
||||||
|
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||||
empty: {
|
empty: {
|
||||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||||
},
|
},
|
||||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||||
|
actionBar: {
|
||||||
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
|
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
|
||||||
|
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
|
||||||
|
},
|
||||||
|
deleteBarButton: {
|
||||||
|
backgroundColor: '#7f1d1d', borderRadius: 10,
|
||||||
|
paddingVertical: 14, alignItems: 'center',
|
||||||
|
},
|
||||||
|
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
|
||||||
import { useActivity, useSetting } from '@/db/queries';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
|
import { deleteActivity, useActivity, useSetting } from '@/db/queries';
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ type Timeseries = {
|
|||||||
export default function ActivityScreen() {
|
export default function ActivityScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const db = useSQLiteContext();
|
||||||
const row = useActivity(id);
|
const row = useActivity(id);
|
||||||
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
|
||||||
const token = useSetting('api_token') ?? '';
|
const token = useSetting('api_token') ?? '';
|
||||||
@@ -34,6 +37,27 @@ export default function ActivityScreen() {
|
|||||||
const [loadingMap, setLoadingMap] = useState(false);
|
const [loadingMap, setLoadingMap] = useState(false);
|
||||||
const [loadingChart, setLoadingChart] = useState(false);
|
const [loadingChart, setLoadingChart] = useState(false);
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
Alert.alert(
|
||||||
|
'Delete activity',
|
||||||
|
'This will permanently remove this activity from your device.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Delete',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
const originalPath = await deleteActivity(db, id);
|
||||||
|
if (originalPath) {
|
||||||
|
try { await FileSystem.deleteAsync(originalPath, { idempotent: true }); } catch {}
|
||||||
|
}
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
|
// instanceUrl and token are in the dep array to avoid a stale-closure bug in
|
||||||
// release builds: Hermes executes effects sooner and captures empty strings if
|
// release builds: Hermes executes effects sooner and captures empty strings if
|
||||||
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
|
// the deps are omitted. Guards on geojson/timeseries prevent double-fetching.
|
||||||
@@ -90,9 +114,14 @@ export default function ActivityScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
<View style={styles.topBar}>
|
||||||
<Text style={styles.backText}>← Back</Text>
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||||
</Pressable>
|
<Text style={styles.backText}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
|
||||||
|
<Text style={styles.deleteText}>Delete</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
||||||
<Text style={styles.title}>{detail.title}</Text>
|
<Text style={styles.title}>{detail.title}</Text>
|
||||||
@@ -367,8 +396,11 @@ const styles = StyleSheet.create({
|
|||||||
content: { paddingBottom: 40 },
|
content: { paddingBottom: 40 },
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
||||||
notFound: { color: '#71717a', fontSize: 16 },
|
notFound: { color: '#71717a', fontSize: 16 },
|
||||||
backButton: { paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12 },
|
topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 },
|
||||||
|
backButton: { paddingHorizontal: 16 },
|
||||||
backText: { color: '#60a5fa', fontSize: 15 },
|
backText: { color: '#60a5fa', fontSize: 15 },
|
||||||
|
deleteButton: { paddingHorizontal: 16 },
|
||||||
|
deleteText: { color: '#f87171', fontSize: 15 },
|
||||||
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
||||||
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
|
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
|
||||||
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||||
|
|||||||
@@ -110,6 +110,32 @@ export async function deleteRemoteActivities(
|
|||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteActivity(
|
||||||
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
|
id: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = db.getFirstSync<{ original_path: string | null }>(
|
||||||
|
'SELECT original_path FROM activities WHERE id = ?',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
||||||
|
return row?.original_path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteActivities(
|
||||||
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<Array<string | null>> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const rows = db.getAllSync<{ original_path: string | null }>(
|
||||||
|
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
||||||
|
return rows.map(r => r.original_path ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getSetting(
|
export async function getSetting(
|
||||||
|
|||||||
Reference in New Issue
Block a user