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)