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:
Davide Scaini
2026-04-25 13:43:12 +02:00
parent c077fceba6
commit 1ac35c84e0
3 changed files with 197 additions and 21 deletions
+133 -15
View File
@@ -1,8 +1,9 @@
import * as FileSystem from 'expo-file-system';
import { useSQLiteContext } from 'expo-sqlite';
import { useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useActivities, type ActivitySummary } from '@/db/queries';
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
import { syncFeed } from '@/db/sync';
export default function FeedScreen() {
@@ -10,6 +11,8 @@ export default function FeedScreen() {
const activities = useActivities();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const selecting = selected.size > 0;
const doSync = useCallback(async () => {
setSyncing(true);
@@ -32,16 +35,64 @@ export default function FeedScreen() {
setTimeout(() => setSyncMsg(null), 3500);
}, [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 (
<View style={styles.container}>
<View style={styles.headerRow}>
<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>
{selecting ? (
<>
<Text style={styles.header}>{selected.size} selected</Text>
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
<Text style={styles.cancelText}>Cancel</Text>
</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>
{syncMsg && (
@@ -60,7 +111,15 @@ export default function FeedScreen() {
<FlatList
data={activities}
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}
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>
);
}
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 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;
@@ -83,13 +162,29 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
day: 'numeric', month: 'short', year: 'numeric',
});
function handlePress() {
if (selecting) {
onToggleSelect();
} else {
router.push(`/activity/${activity.id}`);
}
}
return (
<Pressable
style={styles.card}
onPress={() => router.push(`/activity/${activity.id}`)}
style={[styles.card, checked && styles.cardSelected]}
onPress={handlePress}
onLongPress={onLongPress}
>
<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}>
<Text style={styles.cardDate}>{date}</Text>
{activity.origin === 'remote'
@@ -136,16 +231,23 @@ const styles = StyleSheet.create({
},
syncButtonDisabled: { opacity: 0.5 },
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
cancelButton: {
backgroundColor: '#27272a', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 7,
},
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
syncMsg: {
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
paddingHorizontal: 16, paddingBottom: 8,
},
list: { padding: 16, gap: 12 },
list: { padding: 16, gap: 12, paddingBottom: 80 },
card: {
backgroundColor: '#18181b', borderRadius: 12,
padding: 16, borderWidth: 1, borderColor: '#27272a',
},
cardSelected: { borderColor: '#60a5fa' },
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
sportIcon: { fontSize: 20 },
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
cardDate: { color: '#71717a', fontSize: 12 },
@@ -162,10 +264,26 @@ const styles = StyleSheet.create({
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
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: {
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
},
emptyIcon: { fontSize: 48, marginBottom: 16 },
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
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' },
});