sync details contain bugs

This commit is contained in:
m
2026-07-28 21:21:08 +02:00
parent 9c699446dc
commit 7af44152d7
7 changed files with 269 additions and 54 deletions
+102 -32
View File
@@ -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 (
<TouchableOpacity
style={[styles.gridItem, selected && styles.gridItemSelected]}
onPress={onPress}
onLongPress={onLongPress}
onPress={() => 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}
/>
))}
</View>
@@ -159,15 +160,19 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
return false;
}
const PAGE_SIZE = 100;
export function HomeScreen() {
const navigation = useNavigation<NavigationProp>();
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<SearchFilters>({ name: true, ocrText: true, tags: true });
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string, number>();
@@ -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 (
<View style={styles.section}>
<View style={styles.sectionHeader}>
{groupByTags && <MaterialIcons name="label" size={16} color="#1976D2" style={styles.sectionIcon} />}
<Text style={styles.sectionTitle}>{label}</Text>
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
</View>
<FileGroup
groupFiles={groupFiles}
selectedIds={selectedIds}
onItemPress={handleItemPress}
onItemLongPress={handleItemLongPress}
/>
</View>
);
}, [groups, groupByTags, selectedIds, handleItemPress, handleItemLongPress]);
if (isLoading) {
return (
<View style={styles.center}>
@@ -453,32 +501,39 @@ export function HomeScreen() {
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
refreshControl={
<RefreshControl
refreshing={isFetching && page === 1}
onRefresh={onRefresh}
tintColor="#1976D2"
colors={['#1976D2']}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
{isFetching ? (
<ActivityIndicator size="small" color="#1976D2" />
) : (
<Text style={styles.loadMoreText}>
Charger plus ({loadedFiles}/{totalFiles})
</Text>
)}
</TouchableOpacity>
) : loadedFiles > 0 ? (
<Text style={styles.loadedAllText}>{loadedFiles} fichier{loadedFiles > 1 ? 's' : ''}</Text>
) : null
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>
{searchQuery ? 'Aucun résultat' : 'Aucun fichier'}
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
</Text>
</View>
}
renderItem={({ item: groupKey }) => {
const groupFiles = groups[groupKey];
const label = groupByTags ? groupKey : formatDateLabel(groupKey);
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
{groupByTags && <MaterialIcons name="label" size={16} color="#1976D2" style={styles.sectionIcon} />}
<Text style={styles.sectionTitle}>{label}</Text>
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
</View>
<FileGroup
groupFiles={groupFiles}
selectedIds={selectedIds}
onItemPress={handleItemPress}
onItemLongPress={handleItemLongPress}
/>
</View>
);
}}
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',
+19 -4
View File
@@ -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 }) => (
<View style={styles.fileRow}>
+61 -3
View File
@@ -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<UploadFile[]> => {
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<void>((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));
+12
View File
@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
+15 -14
View File
@@ -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<typeof fileStore.getById>): Unif
};
}
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated>): {
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles>): {
data: UnifiedFileItem[];
meta: { page: number; total: number };
} {
@@ -37,7 +37,7 @@ function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated
};
}
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 50) {
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
const queryKey = parentId
? ['resources', parentId]
: ['resources', 'root', page, limit];
@@ -93,19 +93,20 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
})),
);
const cached = fileStore.getPaginated(page, limit);
return recordsToUnifiedItems(cached);
const cached = fileStore.getRootFiles();
return {
data: cached.files.map((r) => 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,
});
+42
View File
@@ -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<Set<string>>(new Set());
const pollOcr = useCallback(async (resourceId: string): Promise<OcrPollResult> => {
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 };
}
+18 -1
View File
@@ -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<number>`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);
}
});
},