diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index d0f369b..11650af 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -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(null); + const [selected, setSelected] = useState>(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 ( - Feed - - {syncing ? 'Syncing…' : '↓ Sync'} - + {selecting ? ( + <> + {selected.size} selected + + Cancel + + + ) : ( + <> + Feed + + {syncing ? 'Syncing…' : '↓ Sync'} + + + )} {syncMsg && ( @@ -60,7 +111,15 @@ export default function FeedScreen() { a.id} - renderItem={({ item }) => } + renderItem={({ item }) => ( + toggleSelect(item.id)} + onLongPress={() => toggleSelect(item.id)} + /> + )} contentContainerStyle={styles.list} refreshControl={ )} + + {selecting && ( + + + Delete {selected.size} + + + )} ); } -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 ( router.push(`/activity/${activity.id}`)} + style={[styles.card, checked && styles.cardSelected]} + onPress={handlePress} + onLongPress={onLongPress} > - {sportIcon(activity.sport)} + + {selecting && ( + + {checked && } + + )} + {sportIcon(activity.sport)} + {date} {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' }, }); diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index dd22815..a2b4a29 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -1,9 +1,11 @@ import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native'; +import * as FileSystem from 'expo-file-system'; import { useLocalSearchParams, useRouter } from 'expo-router'; 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 { 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'; @@ -25,6 +27,7 @@ type Timeseries = { export default function ActivityScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); + const db = useSQLiteContext(); const row = useActivity(id); const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? ''; const token = useSetting('api_token') ?? ''; @@ -34,6 +37,27 @@ export default function ActivityScreen() { const [loadingMap, setLoadingMap] = 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 // release builds: Hermes executes effects sooner and captures empty strings if // the deps are omitted. Guards on geojson/timeseries prevent double-fetching. @@ -90,9 +114,14 @@ export default function ActivityScreen() { return ( - router.back()}> - ← Back - + + router.back()}> + ← Back + + + Delete + + {detail.sport ?? 'Activity'} {detail.title} @@ -367,8 +396,11 @@ const styles = StyleSheet.create({ content: { paddingBottom: 40 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' }, 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 }, + deleteButton: { paddingHorizontal: 16 }, + deleteText: { color: '#f87171', fontSize: 15 }, 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 }, date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 }, diff --git a/mobile/db/queries.ts b/mobile/db/queries.ts index 7c9443e..3f08fe5 100644 --- a/mobile/db/queries.ts +++ b/mobile/db/queries.ts @@ -110,6 +110,32 @@ export async function deleteRemoteActivities( return result.changes; } +export async function deleteActivity( + db: ReturnType, + id: string, +): Promise { + 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, + ids: string[], +): Promise> { + 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 ─────────────────────────────────────────────────────────────── export async function getSetting(