sync details contain bugs
This commit is contained in:
+102
-32
@@ -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',
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user