diff --git a/docs/mobile-app.md b/docs/mobile-app.md index 9d35e0c..c42d27f 100644 --- a/docs/mobile-app.md +++ b/docs/mobile-app.md @@ -985,41 +985,13 @@ and re-extracted server-side. This section documents mismatches between what the plan describes and what is actually implemented, plus features not yet in the plan. -### Remaining stubs - -**`source_hash` for BAS JSON import is not SHA-256** (`mobile/app/(tabs)/import.tsx`) - -BAS JSON import records `source_hash = "${detail.id}-${text.length}"` β€” a rough -stand-in. FIT/GPX/TCX imports (via Pyodide) correctly compute SHA-256 of the file -bytes. The BAS JSON path still uses the stub; dedup works in practice (activity IDs -are unique) but the hash is not a real content fingerprint. - -**`auto_import_path` only triggers on app open, not in background** +### `auto_import_path` only triggers on app open, not in background The watch-folder scan runs when the Import tab mounts and when the app comes to foreground (`AppState` β†’ `'active'`). There is no true background task that fires while the app is closed. Full background polling would require `expo-background-fetch` -but cannot use the Pyodide WebView (a UI component). - -### Missing from the plan entirely - -**Feed pagination** - -`useActivities()` in `mobile/db/queries.ts` calls `getAllSync` with no `LIMIT`. -This is fine for tens of activities, but will cause noticeable lag at hundreds. -Should add cursor-based pagination or a virtual list with lazy loading. - -**Individual activity deletion** - -There is no way to delete a single activity, local or remote. "Reset synced data" -nukes all remote activities at once. A long-press or swipe-to-delete gesture on -activity cards is needed, with a server-side `DELETE /api/activity/{id}` endpoint -for remote activities. - -**Feed search and filter** - -No search bar, no sport filter, no date picker. The feed is a flat reverse- -chronological list. As the feed grows this becomes a usability problem. +but cannot use the Pyodide WebView (a UI component). Accepted limitation β€” the +on-open trigger covers the primary Karoo use case (open app after a ride). **Token expiry and the reconnect flow** diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 3a457a5..43957fc 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -10,6 +10,11 @@ import { extractFile, waitForEngine, onEngineProgress, isEngineAvailable } from import { extractFileViaServer, checkServerAuth } from '@/extraction/extractServer'; import { useTheme } from '@/ThemeContext'; +async function sha256hex(text: string): Promise { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + const FIT_EXTENSIONS = ['.fit', '.fit.gz']; const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz']; const ALL_NATIVE_EXTENSIONS = [...FIT_EXTENSIONS, ...OTHER_EXTENSIONS]; @@ -243,7 +248,7 @@ export default function ImportScreen() { throw new Error('Not a valid BAS activity JSON (missing id or started_at)'); } - const hash = detail.source_hash ?? `${detail.id}-${text.length}`; + const hash = detail.source_hash ?? await sha256hex(text); const origDir = `${FileSystem.documentDirectory}originals/`; await FileSystem.makeDirectoryAsync(origDir, { intermediates: true }); const dest = `${origDir}${detail.id}.json`; diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 9985505..c8122f9 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -3,16 +3,20 @@ import { useFocusEffect } from 'expo-router'; import { useSQLiteContext } from 'expo-sqlite'; import { useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; -import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'; -import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries'; +import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native'; +import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE, type ActivitySummary } from '@/db/queries'; import { downloadFeed, uploadFeed } from '@/db/sync'; import { useTheme } from '@/ThemeContext'; export default function FeedScreen() { const db = useSQLiteContext(); const theme = useTheme(); - const [refreshKey, setRefreshKey] = useState(0); - const activities = useActivities(); + const [refreshKey, setRefreshKey] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [limit, setLimit] = useState(PAGE_SIZE); + const activities = useActivities(searchQuery, limit); + const totalCount = useActivityCount(searchQuery); + const hasMore = activities.length < totalCount; const [downloading, setDownloading] = useState(false); const [uploading, setUploading] = useState(false); const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null); @@ -73,6 +77,15 @@ export default function FeedScreen() { setRefreshKey(k => k + 1); } + function handleSearch(q: string) { + setSearchQuery(q); + setLimit(PAGE_SIZE); // reset pagination when search changes + } + + function loadMore() { + if (hasMore) setLimit(l => l + PAGE_SIZE); + } + function toggleSelect(id: string) { setSelected(prev => { const next = new Set(prev); @@ -158,6 +171,20 @@ export default function FeedScreen() { {statusMsg.text} )} + {!selecting && ( + + + + )} + {activities.length === 0 && !busy ? ( 🚴 @@ -181,6 +208,8 @@ export default function FeedScreen() { /> )} contentContainerStyle={styles.list} + onEndReached={loadMore} + onEndReachedThreshold={0.3} refreshControl={ ( + `SELECT COUNT(*) as n FROM activities + WHERE (? = '' OR json_extract(detail_json, '$.title') LIKE ?)`, + [searchQuery, like], + ); + return row?.n ?? 0; +} + +export { PAGE_SIZE }; + export function useActivity(id: string): ActivityRow | null { const db = useSQLiteContext(); return db.getFirstSync(