diff --git a/mobile/App.tsx b/mobile/App.tsx index 2d7de82..0633639 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -28,9 +28,11 @@ import { onboardingStorage } from './services/onboardingStorage'; import { initDB } from './services/fileStore'; import { migrateFromLegacy } from './services/fileStore/migrate'; import { createMMKVPersister } from './services/mmkvPersister'; +import { setIsSyncing } from './hooks/useSyncQueue'; initDB(); migrateFromLegacy(); +setIsSyncing(false); const Stack = createNativeStackNavigator(); const queryClient = new QueryClient({ diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 4c0158e..4746aae 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -107,7 +107,7 @@ export function HomeScreen() { const [searchQuery, setSearchQuery] = useState(''); const debouncedSearch = useDebounce(searchQuery, 250); const [filters, setFilters] = useState({ name: true, ocrText: true }); - const [mediaFilter, setMediaFilter] = useState('all'); + const [mediaFilter, setMediaFilter] = useState('documents'); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [numColumns, setNumColumns] = useState(3); @@ -149,6 +149,7 @@ export function HomeScreen() { const totalFiles = data?.meta?.total ?? 0; const loadedFiles = data?.data?.length ?? 0; const hasMore = loadedFiles > 0 && loadedFiles < totalFiles; + const isFiltering = mediaFilter !== 'all' || !!debouncedSearch.trim(); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); @@ -233,6 +234,8 @@ export function HomeScreen() { }); }, [mediaFilteredFiles, uploadGhostItems]); + const displayedCount = sortedFiles.length; + const fileIdToIndex = useMemo(() => { const map = new Map(); sortedFiles.forEach((f, i) => map.set(f.id, i)); @@ -541,12 +544,12 @@ export function HomeScreen() { ) : ( - Charger plus ({loadedFiles}/{totalFiles}) + Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`}) )} - ) : loadedFiles > 0 ? ( - {loadedFiles} fichier{loadedFiles > 1 ? 's' : ''} + ) : displayedCount > 0 ? ( + {displayedCount} fichier{displayedCount > 1 ? 's' : ''} ) : null } ListEmptyComponent={ diff --git a/mobile/app/sync-detail.tsx b/mobile/app/sync-detail.tsx index 32be10e..6f128b8 100644 --- a/mobile/app/sync-detail.tsx +++ b/mobile/app/sync-detail.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { View, Text, @@ -45,11 +45,28 @@ export function SyncDetailScreen() { const { push } = useSyncPush(); const { tasks: uploadTasks, retry, retryAll } = useUploadQueue(); + const [listVersion, setListVersion] = useState(0); + const bumpList = useCallback(() => setListVersion((v) => v + 1), []); + + useEffect(() => { + const interval = setInterval(bumpList, 5000); + return () => clearInterval(interval); + }, [bumpList]); + const pendingFiles = useMemo(() => { return fileStore.getPendingSync(); - }, []); + }, [listVersion]); + + const errorFiles = useMemo(() => { + return fileStore.getErrorFiles(); + }, [listVersion]); + + const uploadErrors = uploadTasks.filter((t) => t.status === 'error'); const handleSyncAll = useCallback(async () => { + for (const f of fileStore.getErrorFiles()) { + fileStore.resetSyncError(f.id); + } await triggerSync(); try { const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration'); @@ -59,7 +76,35 @@ export function SyncDetailScreen() { } } catch { } refresh(); - }, [triggerSync, push, refresh]); + bumpList(); + }, [triggerSync, push, refresh, bumpList]); + + const handleSyncButtonPress = useCallback(async () => { + if (uploadErrors.length > 0) { + retryAll(); + } + await handleSyncAll(); + }, [uploadErrors.length, retryAll, handleSyncAll]); + + const handleErrorFilePress = useCallback((file: FileRecord) => { + Alert.alert( + 'Fichier en erreur', + 'Réessayer la synchronisation de ce fichier ?', + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Réessayer', + onPress: () => { + fileStore.resetSyncError(file.id); + triggerSync().finally(() => { + refresh(); + bumpList(); + }); + }, + }, + ], + ); + }, [triggerSync, refresh, bumpList]); const handleTaskPress = useCallback((task: UploadTask) => { if (task.status !== 'error') return; @@ -75,16 +120,15 @@ export function SyncDetailScreen() { const hasUploads = uploadTasks.length > 0; const hasPending = pendingFiles.length > 0; - const hasContent = hasUploads || hasPending; - - const uploadErrors = uploadTasks.filter((t) => t.status === 'error'); + const hasErrors = errorFiles.length > 0; + const hasContent = hasUploads || hasPending || hasErrors; return ( - {(hasPending || uploadErrors.length > 0) && ( + {(hasPending || hasErrors || uploadErrors.length > 0) && ( 0 && hasPending ? retryAll : handleSyncAll} + onPress={handleSyncButtonPress} disabled={isSyncing} > {isSyncing ? ( @@ -98,11 +142,9 @@ export function SyncDetailScreen() { )} {isSyncing ? 'Synchronisation...' - : uploadErrors.length > 0 && hasPending - ? 'Tout réessayer' - : hasPending - ? `Synchroniser (${pendingCount})` - : `Réessayer (${uploadErrors.length})`} + : uploadErrors.length > 0 + ? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})` + : `Synchroniser (${pendingCount})`} )} @@ -120,13 +162,13 @@ export function SyncDetailScreen() { data={[ ...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []), ...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })), + ...(hasErrors ? [{ type: 'section', label: 'Fichiers en erreur' } as const] : []), + ...errorFiles.map((f) => ({ type: 'error' as const, data: f })), ...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []), ...pendingFiles.map((f) => ({ type: 'file' as const, data: f })), ]} keyExtractor={(item) => - item.type === 'section' ? item.label - : item.type === 'upload' ? item.data.id - : item.data.id + item.type === 'section' ? item.label : item.data.id } renderItem={({ item }) => { if (item.type === 'section') { @@ -161,6 +203,25 @@ export function SyncDetailScreen() { ); } + if (item.type === 'error') { + const file = item.data; + return ( + handleErrorFilePress(file)} + activeOpacity={0.6} + > + + + {file.name} + + Échec de synchronisation · toucher pour réessayer + + + + + ); + } const file = item.data; return ( diff --git a/mobile/hooks/useAutoSync.ts b/mobile/hooks/useAutoSync.ts index 95411b8..6df814b 100644 --- a/mobile/hooks/useAutoSync.ts +++ b/mobile/hooks/useAutoSync.ts @@ -5,6 +5,7 @@ import { File, UploadType } from 'expo-file-system'; import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import { safDirectory, StoredFolder } from '../services/safDirectory'; import { fileStore } from '../services/fileStore'; +import { activeUploadUris } from '../services/uploadQueue'; import { apiClient } from '../api/client'; import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { ApiError } from '../types'; @@ -113,9 +114,12 @@ export function useAutoSync() { setIsSyncing(true); for (const entry of pendingFiles) { + const uri = entry.localUri; + if (!uri || activeUploadUris.has(uri)) continue; + activeUploadUris.add(uri); try { const uploaded = await uploadFile({ - uri: entry.localUri!, + uri, type: entry.mimeType, name: entry.name, }); @@ -134,6 +138,8 @@ export function useAutoSync() { }); resetRetry(entry.id); } + } finally { + activeUploadUris.delete(uri); } } diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts index 1d1e25a..675b61a 100644 --- a/mobile/hooks/useFiles.ts +++ b/mobile/hooks/useFiles.ts @@ -27,16 +27,6 @@ function recordToUnifiedItem(record: ReturnType): Unif }; } -function recordsToUnifiedItems(records: ReturnType): { - data: UnifiedFileItem[]; - meta: { page: number; total: number }; -} { - return { - data: records.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean), - meta: { page: 0, total: records.total }, - }; -} - export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) { const queryKey = parentId ? ['resources', parentId, page, limit] @@ -65,7 +55,6 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb thumbnailUrl: f.thumbnailUrl, })), ); - const children = fileStore.getChildrenByParent(parentId); return { data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean), diff --git a/mobile/hooks/useSyncQueue.ts b/mobile/hooks/useSyncQueue.ts index 835513c..4b229ae 100644 --- a/mobile/hooks/useSyncQueue.ts +++ b/mobile/hooks/useSyncQueue.ts @@ -28,19 +28,23 @@ export function useSyncQueue() { return; } - const registry = fileStore.getPendingSync(); + const allFolders = safDirectory.getAll(); + const autoFolderIds = new Set( + allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id) + ); + + const registry = fileStore.getAllLocal(); let count = 0; for (const entry of registry) { + if (entry.backendId || !entry.localUri) continue; + if (entry.syncStatus !== 'local' && entry.syncStatus !== 'error') continue; if (globalMode === 'auto') { count++; } else { // mode manuel : uniquement les fichiers des dossiers en mode auto - if (!entry.parentResourceId) continue; - const folder = safDirectory.getAll().find((f) => f.id === entry.parentResourceId); - if (folder && folder.syncMode === 'auto') { - count++; - } + if (!entry.parentResourceId || !autoFolderIds.has(entry.parentResourceId)) continue; + count++; } } diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts index 3ef5562..71d931e 100644 --- a/mobile/services/fileStore/index.ts +++ b/mobile/services/fileStore/index.ts @@ -286,6 +286,13 @@ export const fileStore = { return rowToRecord(row, getTagsForFile(row.id)); }, + getByLocalUri(localUri: string): FileRecord | null { + const d = getDb(); + const row = d.select().from(files).where(eq(files.localUri, localUri)).get() as FileRow | undefined; + if (!row) return null; + return rowToRecord(row, getTagsForFile(row.id)); + }, + getRootFolders(): FileRecord[] { const d = getDb(); const rows = d.select().from(files) diff --git a/mobile/services/uploadQueue.ts b/mobile/services/uploadQueue.ts index f839c70..a7677c1 100644 --- a/mobile/services/uploadQueue.ts +++ b/mobile/services/uploadQueue.ts @@ -3,6 +3,7 @@ import { createMMKV } from 'react-native-mmkv'; import { apiClient } from '../api/client'; import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { ApiError, UploadError } from '../types'; +import { fileStore } from './fileStore'; export type UploadFile = { uri: string; type: string; name: string }; export type UploadResult = { name: string; id: string }; @@ -10,6 +11,8 @@ export type UploadResult = { name: string; id: string }; export const UPLOAD_MAX_RETRIES = 3; const BASE_RETRY_DELAY_MS = 1000; +export const activeUploadUris = new Set(); + export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error'; export type UploadTask = { @@ -222,6 +225,7 @@ class UploadQueue { private async runTask(task: UploadTask) { let willRetry = false; + activeUploadUris.add(task.file.uri); try { const fsFile = new File(task.file.uri); const headers: Record = {}; @@ -263,6 +267,7 @@ class UploadQueue { task.progress = 100; task.result = item as UploadResult; task.updatedAt = Date.now(); + this.linkResultToStore(task); this.persist(); this.notify(); this.scheduleCleanup(); @@ -293,6 +298,7 @@ class UploadQueue { } } finally { this.active--; + activeUploadUris.delete(task.file.uri); this.notify(); if (!willRetry) { this.processNext(); @@ -300,6 +306,20 @@ class UploadQueue { } } + private linkResultToStore(task: UploadTask) { + try { + const backendId = task.result?.id; + if (!backendId) return; + const entry = fileStore.getByLocalUri(task.file.uri); + if (!entry || entry.backendId) return; + fileStore.updatePartial(entry.id, { + backendId, + syncStatus: 'synced', + source: 'synced', + }); + } catch {} + } + private scheduleCleanup() { if (this.cleanupTimer) return; this.cleanupTimer = setTimeout(() => {