This commit is contained in:
m
2026-08-25 17:04:17 +02:00
parent 7e7d669d63
commit a791df26bf
8 changed files with 130 additions and 38 deletions
+2
View File
@@ -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({
+7 -4
View File
@@ -107,7 +107,7 @@ export function HomeScreen() {
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 250);
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('all');
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('documents');
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string, number>();
sortedFiles.forEach((f, i) => map.set(f.id, i));
@@ -541,12 +544,12 @@ export function HomeScreen() {
<ActivityIndicator size="small" color="#1976D2" />
) : (
<Text style={styles.loadMoreText}>
Charger plus ({loadedFiles}/{totalFiles})
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
</Text>
)}
</TouchableOpacity>
) : loadedFiles > 0 ? (
<Text style={styles.loadedAllText}>{loadedFiles} fichier{loadedFiles > 1 ? 's' : ''}</Text>
) : displayedCount > 0 ? (
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
) : null
}
ListEmptyComponent={
+77 -16
View File
@@ -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 (
<View style={styles.container}>
{(hasPending || uploadErrors.length > 0) && (
{(hasPending || hasErrors || uploadErrors.length > 0) && (
<TouchableOpacity
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
onPress={uploadErrors.length > 0 && hasPending ? retryAll : handleSyncAll}
onPress={handleSyncButtonPress}
disabled={isSyncing}
>
{isSyncing ? (
@@ -98,11 +142,9 @@ export function SyncDetailScreen() {
)}
<Text style={styles.syncBtnText}>
{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})`}
</Text>
</TouchableOpacity>
)}
@@ -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() {
</TouchableOpacity>
);
}
if (item.type === 'error') {
const file = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, styles.fileRowError]}
onPress={() => handleErrorFilePress(file)}
activeOpacity={0.6}
>
<MaterialIcons name="error" size={20} color="#E53935" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
<Text style={styles.errorText}>
Échec de synchronisation · toucher pour réessayer
</Text>
</View>
<MaterialIcons name="refresh" size={18} color="#E53935" />
</TouchableOpacity>
);
}
const file = item.data;
return (
<View style={styles.fileRow}>
+7 -1
View File
@@ -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);
}
}
-11
View File
@@ -27,16 +27,6 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
};
}
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles>): {
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),
+9 -5
View File
@@ -28,21 +28,25 @@ 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') {
if (!entry.parentResourceId || !autoFolderIds.has(entry.parentResourceId)) continue;
count++;
}
}
}
setPendingCount(count);
setIsSyncingState(getIsSyncing());
+7
View File
@@ -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)
+20
View File
@@ -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<string>();
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<string, string> = {};
@@ -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(() => {