diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 0cf4333..d25701e 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -1,9 +1,10 @@ import * as DocumentPicker from 'expo-document-picker'; import * as FileSystem from 'expo-file-system/legacy'; +import { useFocusEffect } from 'expo-router'; import { useSQLiteContext } from 'expo-sqlite'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { insertActivity, isSourcePathImported, getSetting, useSetting } from '@/db/queries'; +import { insertActivity, isSourcePathImported, getSetting } from '@/db/queries'; import { PyodideWebView } from '@/extraction/PyodideWebView'; import { extractFile } from '@/extraction/extractActivity'; import { useTheme } from '@/ThemeContext'; @@ -22,8 +23,19 @@ export default function ImportScreen() { const db = useSQLiteContext(); const theme = useTheme(); const [state, setState] = useState({ status: 'idle' }); + const [watchPath, setWatchPath] = useState(''); const isImporting = useRef(false); - const watchPath = Platform.OS === 'android' ? (useSetting('auto_import_path') ?? '') : ''; + + // Reload watch path every time the Import tab comes into focus so changes + // saved in Settings are picked up without remounting the tab. + useFocusEffect(useCallback(() => { + if (Platform.OS !== 'android') return; + const row = db.getFirstSync<{ value: string }>( + 'SELECT value FROM settings WHERE key = ?', + ['auto_import_path'], + ); + setWatchPath(row?.value ?? ''); + }, [db])); // Auto-scan watch folder on mount and when app comes to foreground. useEffect(() => { @@ -77,11 +89,26 @@ export default function ImportScreen() { if (isImporting.current) return; setState({ status: 'loading', msg: 'Picking files…', current: 0, total: 0 }); try { - const result = await DocumentPicker.getDocumentAsync({ - type: ['*/*'], - copyToCacheDirectory: true, - multiple: true, - }); + let result: DocumentPicker.DocumentPickerResult; + try { + result = await DocumentPicker.getDocumentAsync({ + type: ['*/*'], + copyToCacheDirectory: true, + multiple: true, + }); + } catch (pickerErr: unknown) { + // Some Android devices (e.g. Karoo) have no system file picker app. + const raw = pickerErr instanceof Error ? pickerErr.message : String(pickerErr); + const noApp = raw.includes('ActivityNotFoundException') || raw.includes('No Activity found'); + setState({ + status: 'error', + message: noApp + ? 'No file picker available on this device. Set a Watch directory in Settings to import from a folder.' + : raw, + }); + return; + } + if (result.canceled || !result.assets?.length) { setState({ status: 'idle' }); return; @@ -93,15 +120,8 @@ export default function ImportScreen() { isImporting.current = false; } } catch (e: unknown) { - 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, - }); + const msg = e instanceof Error ? e.message : String(e); + setState({ status: 'error', message: msg }); isImporting.current = false; } } @@ -240,7 +260,7 @@ export default function ImportScreen() { Watch folder {watchPath} @@ -317,7 +337,7 @@ export default function ImportScreen() { FIT/GPX/TCX extraction runs entirely on your device.{'\n'} A Bincio instance must be reachable on first run to download the extraction engine (~35 MB, then cached).{'\n\n'} - On Karoo or Android: set a Watch directory in Settings to auto-import new FIT files when the app opens. + On Karoo: set Watch directory to /sdcard/FitFiles in Settings to auto-import rides. @@ -433,15 +453,18 @@ const styles = StyleSheet.create({ code: { color: '#60a5fa', fontFamily: 'monospace' }, watchBox: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, - borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 8, + borderColor: '#27272a', padding: 14, marginBottom: 16, gap: 10, + }, + watchLabel: { color: '#71717a', fontSize: 11, fontWeight: '600', letterSpacing: 0.5 }, + watchPath: { color: '#a1a1aa', fontSize: 13, fontFamily: 'monospace' }, + scanButton: { + backgroundColor: '#16a34a', borderRadius: 10, + paddingVertical: 14, alignItems: 'center', }, - 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: { @@ -456,7 +479,7 @@ const styles = StyleSheet.create({ }, successEmpty: { backgroundColor: '#1c1c1e' }, successText: { color: '#86efac', fontSize: 14 }, - batchError: { color: '#fca5a5', fontSize: 12 }, + batchError: { color: '#fca5a5', fontSize: 12 }, error: { backgroundColor: '#450a0a', borderRadius: 8, padding: 12, marginBottom: 16, gap: 8, }, @@ -472,4 +495,5 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a', }, noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 }, + noticeCode: { fontFamily: 'monospace', color: '#a1a1aa' }, }); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index f265b20..16f228e 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -170,7 +170,7 @@ export default function SettingsScreen() {