From 7af44152d7ee8af6b5785285d4295b8cc164dde2 Mon Sep 17 00:00:00 2001 From: m Date: Tue, 28 Jul 2026 21:21:08 +0200 Subject: [PATCH] sync details contain bugs --- mobile/app/index.tsx | 134 ++++++++++++++++++++++------- mobile/app/sync-detail.tsx | 23 ++++- mobile/app/upload.tsx | 64 +++++++++++++- mobile/hooks/useDebounce.ts | 12 +++ mobile/hooks/useFiles.ts | 29 ++++--- mobile/hooks/usePollOcr.ts | 42 +++++++++ mobile/services/fileStore/index.ts | 19 +++- 7 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 mobile/hooks/useDebounce.ts create mode 100644 mobile/hooks/usePollOcr.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index a8b4276..f2ebe1c 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,5 +1,5 @@ -import React, { useState, useMemo, useEffect, useCallback } from 'react'; -import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert, Modal, TextInput, ScrollView } from 'react-native'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert, Modal, TextInput, ScrollView, RefreshControl, ActivityIndicator } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -20,6 +20,7 @@ import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; import { useLocalFiles } from '../hooks/useLocalFiles'; import { deleteAsync } from 'expo-file-system/legacy'; +import { useDebounce } from '../hooks/useDebounce'; const NUM_COLUMNS = 3; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -89,12 +90,12 @@ function formatDateLabel(key: string): string { return key.charAt(0).toUpperCase() + key.slice(1); } -const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) { +const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: (f: UnifiedFileItem) => void; onLongPress?: (f: UnifiedFileItem) => void; selected?: boolean }) { return ( onPress?.(file)} + onLongPress={() => onLongPress?.(file)} delayLongPress={400} activeOpacity={0.7} > @@ -131,8 +132,8 @@ const FileGroup = React.memo(function FileGroup({ groupFiles, selectedIds, onIte key={file.id} file={file} selected={selectedIds.has(file.id)} - onPress={() => onItemPress(file)} - onLongPress={() => onItemLongPress(file)} + onPress={onItemPress} + onLongPress={onItemLongPress} /> ))} @@ -159,15 +160,19 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte return false; } +const PAGE_SIZE = 100; + export function HomeScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { data, isLoading, error } = useFiles(); + const [page, setPage] = useState(1); + const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE); const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles(); const deleteFile = useDeleteFile(); const freeLocalSpace = useFreeLocalSpace(); const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(''); + const debouncedSearch = useDebounce(searchQuery, 250); const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true }); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -185,6 +190,24 @@ export function HomeScreen() { const { pendingCount, isSyncing } = useSyncQueue(); useAutoSync(); + const loadMore = useCallback(() => { + if (isFetching) return; + const total = data?.meta?.total ?? 0; + const loaded = data?.data?.length ?? 0; + if (loaded < total) { + setPage((p) => p + 1); + } + }, [isFetching, data?.meta?.total, data?.data?.length]); + + const onRefresh = useCallback(() => { + setPage(1); + refetch(); + }, [refetch]); + + const totalFiles = data?.meta?.total ?? 0; + const loadedFiles = data?.data?.length ?? 0; + const hasMore = loadedFiles > 0 && loadedFiles < totalFiles; + useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false)); @@ -213,12 +236,16 @@ export function HomeScreen() { const files = data?.data ?? []; const filteredFiles = useMemo( - () => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files, - [files, searchQuery, filters] + () => debouncedSearch.trim() ? files.filter((f) => matchesQuery(f, debouncedSearch, filters)) : files, + [files, debouncedSearch, filters] ); - const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles); - const groupKeys = Object.keys(groups); + const groups = useMemo( + () => groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles), + [filteredFiles, groupByTags] + ); + + const groupKeys = useMemo(() => Object.keys(groups), [groups]); const fileIdToIndex = useMemo(() => { const map = new Map(); @@ -394,6 +421,27 @@ export function HomeScreen() { } }, [selectionMode, toggleSelection]); + const renderSection = useCallback(({ item: groupKey }: { item: string }) => { + const groupFiles = groups[groupKey]; + if (!groupFiles) return null; + const label = groupByTags ? groupKey : formatDateLabel(groupKey); + return ( + + + {groupByTags && } + {label} + {groupFiles.length} + + + + ); + }, [groups, groupByTags, selectedIds, handleItemPress, handleItemLongPress]); + if (isLoading) { return ( @@ -453,32 +501,39 @@ export function HomeScreen() { contentContainerStyle={styles.list} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + hasMore ? ( + + {isFetching ? ( + + ) : ( + + Charger plus ({loadedFiles}/{totalFiles}) + + )} + + ) : loadedFiles > 0 ? ( + {loadedFiles} fichier{loadedFiles > 1 ? 's' : ''} + ) : null + } ListEmptyComponent={ - {searchQuery ? 'Aucun résultat' : 'Aucun fichier'} + {debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'} } - renderItem={({ item: groupKey }) => { - const groupFiles = groups[groupKey]; - const label = groupByTags ? groupKey : formatDateLabel(groupKey); - return ( - - - {groupByTags && } - {label} - {groupFiles.length} - - - - ); - }} + renderItem={renderSection} /> {!selectionMode && ( @@ -650,6 +705,21 @@ export function HomeScreen() { } const styles = StyleSheet.create({ + loadMoreBtn: { + alignItems: 'center', + paddingVertical: 16, + }, + loadMoreText: { + fontSize: 14, + color: '#1976D2', + fontWeight: '600', + }, + loadedAllText: { + textAlign: 'center', + fontSize: 13, + color: '#999', + paddingVertical: 12, + }, container: { flex: 1, backgroundColor: '#f5f5f5', diff --git a/mobile/app/sync-detail.tsx b/mobile/app/sync-detail.tsx index 39a7fe2..82c3ba1 100644 --- a/mobile/app/sync-detail.tsx +++ b/mobile/app/sync-detail.tsx @@ -12,6 +12,8 @@ import { MaterialIcons } from '@expo/vector-icons'; import { fileStore, FileRecord } from '../services/fileStore'; import { safDirectory } from '../services/safDirectory'; import { useSyncQueue } from '../hooks/useSyncQueue'; +import { useAutoSync } from '../hooks/useAutoSync'; +import { useSyncPush } from '../hooks/useSyncPush'; function formatSize(bytes: number): string { if (bytes === 0) return ''; @@ -23,15 +25,28 @@ function formatSize(bytes: number): string { export function SyncDetailScreen() { const insets = useSafeAreaInsets(); const { pendingCount, isSyncing, refresh } = useSyncQueue(); + const { triggerSync } = useAutoSync(); + const { push } = useSyncPush(); const pendingFiles = useMemo(() => { return fileStore.getPendingSync(); }, []); - const handleSyncAll = useCallback(() => { - // TODO: lancer l'upload batch de tous les fichiers pending - console.log(`[SyncDetail] sync demandé pour ${pendingFiles.length} fichiers`); - }, [pendingFiles.length]); + const handleSyncAll = useCallback(async () => { + await triggerSync(); + + try { + const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration'); + const serverId = await getStoredDeviceServerId(); + if (serverId) { + await push(serverId); + } + } catch { + // push notification is best-effort + } + + refresh(); + }, [triggerSync, push, refresh]); const renderItem = useCallback(({ item }: { item: FileRecord }) => ( diff --git a/mobile/app/upload.tsx b/mobile/app/upload.tsx index 2e78659..e815a79 100644 --- a/mobile/app/upload.tsx +++ b/mobile/app/upload.tsx @@ -1,10 +1,13 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import { useUpload, UploadFile } from '../hooks/useUpload'; +import { usePollOcr } from '../hooks/usePollOcr'; import { UploadProgress } from '../components/UploadProgress'; -import { UploadError } from '../types'; +import { UploadError, HttpError } from '../types'; +import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; function getUploadErrorMessage(err: UploadError): string { switch (err.status) { @@ -29,6 +32,40 @@ export function UploadScreen() { const [uploadedCount, setUploadedCount] = useState(0); const [totalCount, setTotalCount] = useState(0); const upload = useUpload(); + const { pollOcr } = usePollOcr(); + + const checkDupBeforeUpload = useCallback(async (files: UploadFile[]): Promise => { + const toUpload: UploadFile[] = []; + for (const file of files) { + try { + const result = await apiClient.post<{ data: { duplicates: Array<{ id: string; name: string }>; count: number } }>( + ENDPOINTS.DEDUP_CHECK, + { name: file.name, size: 0 }, + ); + const duplicates = result.data?.duplicates ?? []; + if (duplicates.length > 0) { + const names = duplicates.map((d: { name: string }) => d.name).join(', '); + let proceed = false; + await new Promise((resolve) => { + Alert.alert( + 'Fichier existant', + `"${file.name}" existe déjà sur le serveur (${names}).\nUploader quand même ?`, + [ + { text: 'Ignorer', style: 'cancel', onPress: () => resolve() }, + { text: 'Uploader', onPress: () => { proceed = true; resolve(); } }, + ], + ); + }); + if (proceed) toUpload.push(file); + } else { + toUpload.push(file); + } + } catch { + toUpload.push(file); + } + } + return toUpload; + }, []); const doUpload = async (files: UploadFile[]) => { setUploadStatus('uploading'); @@ -37,9 +74,30 @@ export function UploadScreen() { setError(undefined); try { - const response = await upload.mutateAsync(files); + const deduped = await checkDupBeforeUpload(files); + if (deduped.length === 0) { + setUploadStatus('success'); + setUploadedCount(files.length); + return; + } + + setTotalCount(deduped.length); + const response = await upload.mutateAsync(deduped); setUploadedCount(response.uploaded.length); + if (response.uploaded.length > 0) { + setUploadStatus('processing'); + const results = await Promise.allSettled( + response.uploaded.map((f) => pollOcr(f.id)), + ); + const completed = results.filter( + (r) => r.status === 'fulfilled' && r.value.status === 'completed', + ).length; + if (completed > 0) { + setUploadStatus('success'); + } + } + if (response.errors.length > 0) { setUploadStatus('error'); const messages = response.errors.map((e) => getUploadErrorMessage(e)); diff --git a/mobile/hooks/useDebounce.ts b/mobile/hooks/useDebounce.ts new file mode 100644 index 0000000..0ca7902 --- /dev/null +++ b/mobile/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + + return debounced; +} diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts index 758130c..7b571c3 100644 --- a/mobile/hooks/useFiles.ts +++ b/mobile/hooks/useFiles.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types'; @@ -27,7 +27,7 @@ function recordToUnifiedItem(record: ReturnType): Unif }; } -function recordsToUnifiedItems(records: ReturnType): { +function recordsToUnifiedItems(records: ReturnType): { data: UnifiedFileItem[]; meta: { page: number; total: number }; } { @@ -37,7 +37,7 @@ function recordsToUnifiedItems(records: ReturnType recordToUnifiedItem(r)!).filter(Boolean), + meta: { page, total: backendRes.meta?.total ?? cached.total }, + }; }, + placeholderData: keepPreviousData, initialData: () => { - let records; - if (parentId) { - const children = fileStore.getChildrenByParent(parentId); - records = { files: children, total: children.length }; - } else { - records = fileStore.getPaginated(page, limit); - } - if (records.files.length === 0) return undefined; - return recordsToUnifiedItems(records); + const cached = fileStore.getRootFiles(); + if (cached.files.length === 0) return undefined; + return { + data: cached.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean), + meta: { page: 0, total: cached.total }, + }; }, staleTime: 30_000, }); diff --git a/mobile/hooks/usePollOcr.ts b/mobile/hooks/usePollOcr.ts new file mode 100644 index 0000000..68614cf --- /dev/null +++ b/mobile/hooks/usePollOcr.ts @@ -0,0 +1,42 @@ +import { useCallback, useRef } from 'react'; +import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; +import { fileStore } from '../services/fileStore'; + +const MAX_POLL_MS = 120_000; +const POLL_INTERVAL = 3_000; + +export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' }; + +export function usePollOcr() { + const running = useRef>(new Set()); + + const pollOcr = useCallback(async (resourceId: string): Promise => { + if (running.current.has(resourceId)) return { resourceId, status: 'failed' }; + running.current.add(resourceId); + + const start = Date.now(); + try { + while (Date.now() - start < MAX_POLL_MS) { + try { + const detail = await apiClient.get<{ data: { ocrText?: string } }>( + `${ENDPOINTS.RESOURCES}/${resourceId}`, + ); + const ocrText = detail.data?.ocrText; + if (ocrText && ocrText.length > 0) { + fileStore.updatePartial(resourceId, { ocrText }); + return { resourceId, status: 'completed' }; + } + } catch { + return { resourceId, status: 'failed' }; + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + } + return { resourceId, status: 'timeout' }; + } finally { + running.current.delete(resourceId); + } + }, []); + + return { pollOcr }; +} diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts index ddfaa84..8420298 100644 --- a/mobile/services/fileStore/index.ts +++ b/mobile/services/fileStore/index.ts @@ -304,6 +304,23 @@ export const fileStore = { return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, + getRootFiles(): { files: FileRecord[]; total: number } { + const d = getDb(); + const countRow = d.select({ count: sql`count(*)` }) + .from(files) + .where(and(isNull(files.parentResourceId), eq(files.isFolder, 0))) + .get(); + const total = countRow?.count ?? 0; + const rows = d.select().from(files) + .where(and(isNull(files.parentResourceId), eq(files.isFolder, 0))) + .orderBy(desc(files.createdAt)) + .all() as FileRow[]; + return { + files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))), + total, + }; + }, + getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } { const d = getDb(); const offset = (page - 1) * limit; @@ -414,7 +431,7 @@ export const fileStore = { lastSyncedAt: now, }); - if (bf.tags) setTagsForFile(bf.id, bf.tags); + if (bf.tags && bf.tags.length > 0) setTagsForFile(bf.id, bf.tags); } }); },