From 479c8ab61cc8f88f5e065dcb9c8a182fdb68f8df Mon Sep 17 00:00:00 2001 From: m Date: Sat, 29 Aug 2026 09:31:53 +0200 Subject: [PATCH] use auto sync --- mobile/App.tsx | 4 +- mobile/app/sync-detail.tsx | 243 ++++++++++++++++++++--------- mobile/hooks/useAutoSync.ts | 39 ++++- mobile/hooks/useSyncQueue.ts | 119 ++++++++++++-- mobile/services/fileStore/index.ts | 15 +- 5 files changed, 327 insertions(+), 93 deletions(-) diff --git a/mobile/App.tsx b/mobile/App.tsx index 0633639..f84964d 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -28,11 +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'; +import { resetSyncState } from './hooks/useSyncQueue'; initDB(); migrateFromLegacy(); -setIsSyncing(false); +resetSyncState(); const Stack = createNativeStackNavigator(); const queryClient = new QueryClient({ diff --git a/mobile/app/sync-detail.tsx b/mobile/app/sync-detail.tsx index f3ce79e..5d98678 100644 --- a/mobile/app/sync-detail.tsx +++ b/mobile/app/sync-detail.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'; import { View, Text, @@ -7,12 +7,12 @@ import { TouchableOpacity, ActivityIndicator, Alert, + Animated, + Easing, } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { fileStore, FileRecord } from '../services/fileStore'; -import { safDirectory } from '../services/safDirectory'; -import { useSyncQueue } from '../hooks/useSyncQueue'; +import { useSyncQueue, useSyncProgress } from '../hooks/useSyncQueue'; import { useAutoSync } from '../hooks/useAutoSync'; import { useSyncPush } from '../hooks/useSyncPush'; import { useUploadQueue } from '../hooks/useUploadQueue'; @@ -31,10 +31,117 @@ function uploadStatusIcon(task: UploadTask) { } } +function SpinningSyncIcon({ size, color }: { size: number; color: string }) { + const spin = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 1200, + easing: Easing.linear, + useNativeDriver: true, + }) + ); + animation.start(); + return () => animation.stop(); + }, [spin]); + + const rotate = spin.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + + + + ); +} + +type PendingFile = { id: string; name: string }; + +const PendingSyncCard = React.memo(function PendingSyncCard({ + syncing, + pendingCount, + shortList, + onSyncPress, + onCancel, +}: { + syncing: boolean; + pendingCount: number; + shortList: PendingFile[] | null; + onSyncPress: () => void; + onCancel: () => void; +}) { + return ( + + + + {syncing ? ( + + ) : ( + + )} + + + + {syncing + ? 'Synchronisation en cours...' + : pendingCount > 1 + ? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés` + : 'Vous avez 1 fichier local pouvant être synchronisé'} + + + {syncing + ? shortList && shortList[0] + ? `En cours : ${shortList[0].name}` + : 'Synchronisation en cours...' + : 'Appuyer maintenant pour les synchroniser'} + + + {!syncing && } + + + {syncing && shortList && shortList.length > 0 && ( + + {shortList.map((f, i) => ( + + {i === 0 ? ( + + ) : ( + + )} + + {f.name} + + + ))} + + )} + + {syncing && ( + + + Arrêter la synchronisation + + )} + + ); +}); + export function SyncDetailScreen() { - const insets = useSafeAreaInsets(); const { pendingCount, isSyncing, refresh } = useSyncQueue(); - const { syncManually } = useAutoSync(); + const { syncProgress } = useSyncProgress(); + const { syncManually, cancelSync } = useAutoSync(); const { push } = useSyncPush(); const { tasks: uploadTasks, retry, retryAll } = useUploadQueue(); @@ -107,37 +214,21 @@ export function SyncDetailScreen() { ); }, [retry]); + const syncing = !!syncProgress; const hasUploads = uploadTasks.length > 0; - const hasPending = pendingCount > 0; + const hasPending = pendingCount > 0 || syncing; const hasErrors = errorFiles.length > 0; const hasContent = hasUploads || hasPending || hasErrors; + const shortList = useMemo(() => { + if (!syncProgress) return null; + const { files, currentIndex } = syncProgress; + const start = Math.max(0, currentIndex); + return files.slice(start, start + 5); + }, [syncProgress]); + return ( - {(hasPending || hasErrors || uploadErrors.length > 0) && ( - - {isSyncing ? ( - - ) : ( - 0 ? "refresh" : "cloud-upload"} - size={20} - color="#fff" - /> - )} - - {isSyncing ? 'Synchronisation...' - : uploadErrors.length > 0 - ? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})` - : `Synchroniser (${pendingCount})`} - - - )} - {!hasContent ? ( @@ -162,33 +253,20 @@ export function SyncDetailScreen() { : item.label : item.data.id } + extraData={[isSyncing, syncProgress]} renderItem={({ item }) => { if (item.type === 'section') { return {item.label}; } if (item.type === 'pendingCard') { return ( - - - - - - - {pendingCount > 1 - ? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés` - : 'Vous avez 1 fichier local pouvant être synchronisé'} - - - Appuyer maintenant pour les synchroniser - - - - + ); } if (item.type === 'upload') { @@ -253,25 +331,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#f5f5f5', }, - syncBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#1976D2', - marginHorizontal: 16, - marginTop: 16, - borderRadius: 10, - paddingVertical: 14, - gap: 10, - }, - syncBtnDisabled: { - backgroundColor: '#90CAF9', - }, - syncBtnText: { - fontSize: 16, - color: '#fff', - fontWeight: '600', - }, list: { padding: 16, }, @@ -290,8 +349,6 @@ const styles = StyleSheet.create({ gap: 12, }, pendingCard: { - flexDirection: 'row', - alignItems: 'center', backgroundColor: '#fff', borderRadius: 14, padding: 16, @@ -303,6 +360,11 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 2 }, elevation: 2, }, + pendingCardTop: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, pendingCardIcon: { width: 48, height: 48, @@ -324,6 +386,41 @@ const styles = StyleSheet.create({ color: '#1976D2', marginTop: 4, }, + pendingList: { + borderTopWidth: 1, + borderTopColor: '#f0f0f0', + paddingTop: 12, + gap: 8, + }, + pendingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + pendingRowText: { + flex: 1, + fontSize: 13, + color: '#999', + }, + pendingRowActive: { + color: '#1976D2', + fontWeight: '500', + }, + stopBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: '#E53935', + borderRadius: 8, + paddingVertical: 10, + marginTop: 4, + }, + stopBtnText: { + fontSize: 14, + color: '#fff', + fontWeight: '600', + }, fileInfo: { flex: 1, }, diff --git a/mobile/hooks/useAutoSync.ts b/mobile/hooks/useAutoSync.ts index df2205d..db05e19 100644 --- a/mobile/hooks/useAutoSync.ts +++ b/mobile/hooks/useAutoSync.ts @@ -9,7 +9,15 @@ import { activeUploadUris } from '../services/uploadQueue'; import { apiClient } from '../api/client'; import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { ApiError } from '../types'; -import { setIsSyncing } from './useSyncQueue'; +import { + setIsSyncing, + setSyncProgress, + isSyncLoopRunning, + setSyncLoopRunning, + requestSyncCancel, + isSyncCancelRequested, + consumeSyncCancel, +} from './useSyncQueue'; const MAX_RETRIES = 5; const RETRY_MMKV_ID = 'vaultdrop-sync-retries'; @@ -73,6 +81,8 @@ function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean): return true; } +let autoSyncLoopStarted = false; + export function useAutoSync() { const queryClient = useQueryClient(); const isRunning = useRef(false); @@ -83,8 +93,13 @@ export function useAutoSync() { ); }, []); + const cancelSync = useCallback(() => { + requestSyncCancel(); + }, []); + const runPendingSync = useCallback(async (pendingFiles: Array[number]>) => { if (isRunning.current) return; + if (isSyncLoopRunning()) return; const globalCellular = safDirectory.getGlobalSyncCellular(); const netInfo = await NetInfo.fetch(); @@ -92,11 +107,18 @@ export function useAutoSync() { if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return; isRunning.current = true; + setSyncLoopRunning(true); try { setIsSyncing(true); + consumeSyncCancel(); - for (const entry of pendingFiles) { + const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name })); + + for (let index = 0; index < pendingFiles.length; index++) { + if (isSyncCancelRequested()) break; + const entry = pendingFiles[index]; + setSyncProgress({ files: progressFiles, currentIndex: index }); const uri = entry.localUri; if (!uri || activeUploadUris.has(uri)) continue; activeUploadUris.add(uri); @@ -126,10 +148,16 @@ export function useAutoSync() { } } + if (consumeSyncCancel()) { + return; + } + queryClient.invalidateQueries({ queryKey: ['resources'] }); } finally { + setSyncProgress(null); setIsSyncing(false); isRunning.current = false; + setSyncLoopRunning(false); } }, [queryClient]); @@ -161,12 +189,15 @@ export function useAutoSync() { }, [getPendingFiles, runPendingSync]); useEffect(() => { + if (autoSyncLoopStarted) return; + autoSyncLoopStarted = true; + const timeout = setTimeout(() => { checkAndSync(); }, 5_000); const interval = setInterval(checkAndSync, 30_000); - return () => { clearTimeout(timeout); clearInterval(interval); }; + return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; }; }, [checkAndSync]); - return { triggerSync: checkAndSync, syncManually }; + return { triggerSync: checkAndSync, syncManually, cancelSync }; } diff --git a/mobile/hooks/useSyncQueue.ts b/mobile/hooks/useSyncQueue.ts index 61f5f0b..dddb580 100644 --- a/mobile/hooks/useSyncQueue.ts +++ b/mobile/hooks/useSyncQueue.ts @@ -1,8 +1,22 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useSyncExternalStore } from 'react'; import { createMMKV } from 'react-native-mmkv'; import { fileStore } from '../services/fileStore'; +export type SyncProgress = { + files: Array<{ id: string; name: string }>; + currentIndex: number; +} | null; + const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' }); +const PROGRESS_KEY = 'sync_progress'; + +let syncRunning = false; +let cancelRequested = false; +const listeners = new Set<() => void>(); + +function notify() { + listeners.forEach((l) => l()); +} export function getIsSyncing(): boolean { return syncStateStorage.getString('is_syncing') === 'true'; @@ -10,24 +24,98 @@ export function getIsSyncing(): boolean { export function setIsSyncing(value: boolean) { syncStateStorage.set('is_syncing', value ? 'true' : 'false'); + notify(); +} + +export function getSyncProgress(): SyncProgress { + return parseProgress(syncStateStorage.getString(PROGRESS_KEY)); +} + +function parseProgress(raw: string | undefined): SyncProgress { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number }; + if (!parsed || !Array.isArray(parsed.files)) return null; + return parsed; + } catch { + return null; + } +} + +export function setSyncProgress(value: SyncProgress) { + if (value === null) { + syncStateStorage.remove(PROGRESS_KEY); + } else { + syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value)); + } + notify(); +} + +export function isSyncLoopRunning(): boolean { + return syncRunning; +} + +export function setSyncLoopRunning(value: boolean) { + syncRunning = value; +} + +export function requestSyncCancel() { + cancelRequested = true; +} + +export function isSyncCancelRequested(): boolean { + return cancelRequested; +} + +export function consumeSyncCancel(): boolean { + const value = cancelRequested; + cancelRequested = false; + return value; +} + +export function resetSyncState() { + syncStateStorage.remove('is_syncing'); + syncStateStorage.remove(PROGRESS_KEY); + syncRunning = false; + cancelRequested = false; + notify(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +let cachedIsSyncing = getIsSyncing(); +let lastIsSyncingRaw: string | undefined; + +function getIsSyncingSnapshot(): boolean { + const raw = syncStateStorage.getString('is_syncing'); + if (raw !== lastIsSyncingRaw) { + lastIsSyncingRaw = raw; + cachedIsSyncing = raw === 'true'; + } + return cachedIsSyncing; +} + +let cachedProgress = getSyncProgress(); +let lastProgressRaw: string | undefined; + +function getSyncProgressSnapshot(): SyncProgress { + const raw = syncStateStorage.getString(PROGRESS_KEY); + if (raw !== lastProgressRaw) { + lastProgressRaw = raw; + cachedProgress = parseProgress(raw); + } + return cachedProgress; } export function useSyncQueue() { + const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot); const [pendingCount, setPendingCount] = useState(0); - const [isSyncing, setIsSyncingState] = useState(() => getIsSyncing()); const refresh = useCallback(() => { - 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; - count++; - } - - setPendingCount(count); - setIsSyncingState(getIsSyncing()); + setPendingCount(fileStore.countPendingSync()); }, []); useEffect(() => { @@ -38,3 +126,8 @@ export function useSyncQueue() { return { pendingCount, isSyncing, refresh }; } + +export function useSyncProgress() { + const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot); + return { syncProgress }; +} \ No newline at end of file diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts index a7fd0b7..d53b1b3 100644 --- a/mobile/services/fileStore/index.ts +++ b/mobile/services/fileStore/index.ts @@ -1,6 +1,6 @@ import { drizzle } from 'drizzle-orm/expo-sqlite'; import * as SQLite from 'expo-sqlite'; -import { eq, like, or, and, desc, asc, sql, isNull } from 'drizzle-orm'; +import { eq, like, or, and, desc, asc, sql, isNull, inArray, isNotNull } from 'drizzle-orm'; import { files, fileTags, deletedFiles, pendingActions } from './schema'; import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types'; @@ -590,6 +590,19 @@ export const fileStore = { return row?.count ?? 0; }, + countPendingSync(): number { + const d = getDb(); + const row = d.select({ count: sql`count(*)` }).from(files).where( + and( + or(eq(files.source, 'local'), eq(files.source, 'synced')), + isNull(files.backendId), + isNotNull(files.localUri), + inArray(files.syncStatus, ['local', 'error']), + ) + ).get(); + return row?.count ?? 0; + }, + getAllLocal(): FileRecord[] { const d = getDb(); const rows = d.select().from(files)