feat(mobile): watch-folder scan button + Karoo file picker fix + docs
Import screen: - Add "Scan for new rides" button (green) when auto_import_path is set; shows the configured path, lets user trigger manually in addition to the automatic scan on app open. - Detect ActivityNotFoundException from DocumentPicker (Karoo and other stripped Android devices have no DocumentsUI app) and show a friendly message directing users to set a Watch directory instead. - "No new rides found" feedback when manual scan finds nothing. Docs (docs/mobile-app.md): - Phase 1 marked complete with implementation notes (wheel delivery, timeseries workaround, source_path dedup). - Phase 2 updated to reflect what is actually implemented (on-open scan, not background task) vs what remains (true background polling). - Batch import moved from Phase 5 todo to done. - Data model updated with source_path column. - Known gaps section revised to remove fixed stubs. - New Karoo sideloading section with debuggableVariants and armeabi-v7a troubleshooting notes.
This commit is contained in:
@@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries';
|
||||
import { insertActivity, isSourcePathImported, getSetting, useSetting } from '@/db/queries';
|
||||
import { PyodideWebView } from '@/extraction/PyodideWebView';
|
||||
import { extractFile } from '@/extraction/extractActivity';
|
||||
import { useTheme } from '@/ThemeContext';
|
||||
@@ -23,6 +23,7 @@ export default function ImportScreen() {
|
||||
const theme = useTheme();
|
||||
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||
const isImporting = useRef(false);
|
||||
const watchPath = Platform.OS === 'android' ? (useSetting('auto_import_path') ?? '') : '';
|
||||
|
||||
// Auto-scan watch folder on mount and when app comes to foreground.
|
||||
useEffect(() => {
|
||||
@@ -38,10 +39,10 @@ export default function ImportScreen() {
|
||||
|
||||
async function runAutoScan() {
|
||||
if (isImporting.current) return;
|
||||
const watchPath = await getSetting(db, 'auto_import_path');
|
||||
if (!watchPath) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
|
||||
const newFiles = await discoverNewFiles(db, watchPath);
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
isImporting.current = true;
|
||||
@@ -52,6 +53,26 @@ export default function ImportScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function manualScan() {
|
||||
if (isImporting.current) return;
|
||||
const path = await getSetting(db, 'auto_import_path');
|
||||
if (!path) return;
|
||||
|
||||
setState({ status: 'loading', msg: 'Scanning…', current: 0, total: 0 });
|
||||
const newFiles = await discoverNewFiles(db, path);
|
||||
if (newFiles.length === 0) {
|
||||
setState({ status: 'done', count: 0, errors: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.current = true;
|
||||
try {
|
||||
await processBatch(newFiles.map(f => ({ uri: `file://${f}`, name: f.split('/').pop() ?? f, sourcePath: f })));
|
||||
} finally {
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFiles() {
|
||||
if (isImporting.current) return;
|
||||
setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 });
|
||||
@@ -72,8 +93,15 @@ export default function ImportScreen() {
|
||||
isImporting.current = false;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setState({ status: 'error', message: msg });
|
||||
const raw = e instanceof Error ? e.message : String(e);
|
||||
// Karoo and other stripped-down Android devices have no DocumentsUI app
|
||||
const noPickerAvailable = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found');
|
||||
setState({
|
||||
status: 'error',
|
||||
message: noPickerAvailable
|
||||
? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder automatically.'
|
||||
: raw,
|
||||
});
|
||||
isImporting.current = false;
|
||||
}
|
||||
}
|
||||
@@ -205,9 +233,23 @@ export default function ImportScreen() {
|
||||
<Text style={styles.body}>
|
||||
Import FIT, GPX, or TCX files — extracted on your device, nothing uploaded.
|
||||
You can also import pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> files.
|
||||
Select multiple files at once to import in batch.
|
||||
</Text>
|
||||
|
||||
{watchPath ? (
|
||||
<View style={styles.watchBox}>
|
||||
<Text style={styles.watchLabel}>Watch folder</Text>
|
||||
<Text style={styles.watchPath} numberOfLines={2}>{watchPath}</Text>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonWatch, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? manualScan : undefined}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.status === 'loading' ? 'Working…' : '↺ Scan for new rides'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
||||
onPress={state.status !== 'loading' ? pickFiles : undefined}
|
||||
@@ -232,15 +274,17 @@ export default function ImportScreen() {
|
||||
)}
|
||||
|
||||
{state.status === 'done' && (
|
||||
<View style={styles.success}>
|
||||
<View style={[styles.success, state.count === 0 && state.errors.length === 0 && styles.successEmpty]}>
|
||||
<Text style={styles.successText}>
|
||||
✓ Imported {state.count} {state.count === 1 ? 'activity' : 'activities'}
|
||||
{state.count === 0 && state.errors.length === 0
|
||||
? 'No new rides found'
|
||||
: `✓ Imported ${state.count} ${state.count === 1 ? 'activity' : 'activities'}`}
|
||||
</Text>
|
||||
{state.errors.map((e, i) => (
|
||||
<Text key={i} style={styles.batchError}>✗ {e.name}: {e.message}</Text>
|
||||
))}
|
||||
<Pressable onPress={() => setState({ status: 'idle' })}>
|
||||
<Text style={styles.errorRetry}>Import more</Text>
|
||||
<Text style={styles.errorRetry}>Dismiss</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
@@ -387,10 +431,17 @@ const styles = StyleSheet.create({
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||
watchBox: {
|
||||
backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1,
|
||||
borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 8,
|
||||
},
|
||||
watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 },
|
||||
watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' },
|
||||
button: {
|
||||
backgroundColor: '#2563eb', borderRadius: 10,
|
||||
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||||
},
|
||||
buttonWatch: { backgroundColor: '#16a34a', marginBottom: 0 },
|
||||
buttonDisabled: { opacity: 0.5 },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
statusBox: {
|
||||
@@ -403,7 +454,8 @@ const styles = StyleSheet.create({
|
||||
success: {
|
||||
backgroundColor: '#14532d', borderRadius: 8, padding: 12, marginBottom: 16, gap: 6,
|
||||
},
|
||||
successText: { color: '#86efac', fontSize: 14 },
|
||||
successEmpty: { backgroundColor: '#1c1c1e' },
|
||||
successText: { color: '#86efac', fontSize: 14 },
|
||||
batchError: { color: '#fca5a5', fontSize: 12 },
|
||||
error: {
|
||||
backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8,
|
||||
|
||||
Reference in New Issue
Block a user