diff --git a/mobile/App.tsx b/mobile/App.tsx
index f60ce8d..cca7ca6 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -18,6 +18,7 @@ import { FileDetailScreen } from './app/file-detail';
import { FileEditScreen } from './app/file-edit';
import { FolderScreen } from './app/folder';
import { OnboardingScreen } from './app/onboarding';
+import { SyncDetailScreen } from './app/sync-detail';
import { onboardingStorage } from './services/onboardingStorage';
const Stack = createNativeStackNavigator();
@@ -66,6 +67,7 @@ function AppNavigator() {
/>
+
>
) : (
<>
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index e30a5a9..036113e 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -10,7 +10,11 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail';
-import { safDirectory, StoredFolder } from '../services/safDirectory';
+import { SettingsModal } from '../components/SettingsModal';
+import { SyncStatusIcon } from '../components/SyncStatusIcon';
+import { useSyncQueue } from '../hooks/useSyncQueue';
+import { useAutoSync } from '../hooks/useAutoSync';
+import { safDirectory, StoredFolder, SyncMode } from '../services/safDirectory';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -23,6 +27,7 @@ type RootStackParamList = {
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record };
FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string };
+ SyncDetail: undefined;
};
type NavigationProp = NativeStackNavigationProp;
@@ -149,7 +154,9 @@ export function HomeScreen() {
const moveFiles = useMoveFiles();
const { data: foldersData } = useFolders();
const [moveModalVisible, setMoveModalVisible] = useState(false);
- const [folderSheetVisible, setFolderSheetVisible] = useState(false);
+ const [settingsModalVisible, setSettingsModalVisible] = useState(false);
+ const { pendingCount, isSyncing } = useSyncQueue();
+ useAutoSync();
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -160,12 +167,19 @@ export function HomeScreen() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
- setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
-
-
+
+ navigation.navigate('SyncDetail')}
+ />
+ setSettingsModalVisible(true)} style={{ marginRight: 4, padding: 8 }}>
+
+
+
),
});
- }, [navigation]);
+ }, [navigation, pendingCount, isSyncing]);
const selectionMode = selectedIds.size > 0;
@@ -305,10 +319,20 @@ export function HomeScreen() {
}, [refreshFolders]);
const handleAddFolder = useCallback(async () => {
- setFolderSheetVisible(false);
+ setSettingsModalVisible(false);
await pickDirectory();
}, [pickDirectory]);
+ const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
+ safDirectory.updateSyncMode(folderId, mode);
+ refreshFolders();
+ }, [refreshFolders]);
+
+ const handleUpdateSyncCellular = useCallback((folderId: string, enabled: boolean) => {
+ safDirectory.updateSyncCellular(folderId, enabled);
+ refreshFolders();
+ }, [refreshFolders]);
+
const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) {
toggleSelection(file.id);
@@ -584,44 +608,16 @@ export function HomeScreen() {
- setFolderSheetVisible(false)}>
- setFolderSheetVisible(false)}>
- {}}>
-
- Mes dossiers
- {folders.length === 0 ? (
- Aucun dossier configuré
- ) : (
- folders.map((folder) => (
-
-
- {folder.name}
- handleToggleFolderVisibility(folder.id)}
- style={styles.folderRowAction}
- >
-
-
- handleRemoveFolder(folder.id)}
- style={styles.folderRowAction}
- >
-
-
-
- ))
- )}
-
-
- Ajouter un dossier
-
-
-
-
+ setSettingsModalVisible(false)}
+ folders={folders}
+ onToggleVisibility={handleToggleFolderVisibility}
+ onRemoveFolder={handleRemoveFolder}
+ onAddFolder={handleAddFolder}
+ onUpdateSyncMode={handleUpdateSyncMode}
+ onUpdateSyncCellular={handleUpdateSyncCellular}
+ />
);
}
@@ -884,69 +880,4 @@ const styles = StyleSheet.create({
color: '#fff',
fontWeight: '600',
},
- emptyFoldersText: {
- fontSize: 14,
- color: '#999',
- textAlign: 'center',
- marginBottom: 16,
- },
- folderRow: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingVertical: 10,
- gap: 10,
- borderBottomWidth: 1,
- borderBottomColor: '#f0f0f0',
- },
- folderRowName: {
- flex: 1,
- fontSize: 15,
- color: '#333',
- },
- folderRowAction: {
- padding: 6,
- },
- addFolderBtn: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '#F57C00',
- borderRadius: 10,
- paddingVertical: 12,
- marginTop: 16,
- gap: 8,
- },
- addFolderBtnText: {
- fontSize: 15,
- color: '#fff',
- fontWeight: '600',
- },
- bottomSheetOverlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.4)',
- justifyContent: 'flex-end',
- },
- bottomSheet: {
- backgroundColor: '#fff',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingHorizontal: 20,
- paddingTop: 12,
- paddingBottom: 32,
- maxHeight: '70%',
- },
- bottomSheetHandle: {
- width: 36,
- height: 4,
- borderRadius: 2,
- backgroundColor: '#ddd',
- alignSelf: 'center',
- marginBottom: 16,
- },
- bottomSheetTitle: {
- fontSize: 18,
- fontWeight: '700',
- color: '#333',
- marginBottom: 16,
- },
});
diff --git a/mobile/app/onboarding.tsx b/mobile/app/onboarding.tsx
index 068ac7c..56f223d 100644
--- a/mobile/app/onboarding.tsx
+++ b/mobile/app/onboarding.tsx
@@ -28,7 +28,7 @@ export function OnboardingScreen() {
const complete = useCallback(() => {
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
- navigation.navigate('Home' as never);
+ navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] });
}, [navigation]);
const handlePickDirectory = useCallback(async () => {
diff --git a/mobile/app/sync-detail.tsx b/mobile/app/sync-detail.tsx
new file mode 100644
index 0000000..9d53ad1
--- /dev/null
+++ b/mobile/app/sync-detail.tsx
@@ -0,0 +1,176 @@
+import React, { useMemo, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ FlatList,
+ TouchableOpacity,
+ ActivityIndicator,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { MaterialIcons } from '@expo/vector-icons';
+import { localFileRegistry } from '../services/localFileRegistry';
+import { safDirectory } from '../services/safDirectory';
+import { useSyncQueue } from '../hooks/useSyncQueue';
+import { LocalFileEntry } from '../types';
+
+function formatSize(bytes: number): string {
+ if (bytes === 0) return '';
+ if (bytes < 1024) return `${bytes} o`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
+}
+
+export function SyncDetailScreen() {
+ const insets = useSafeAreaInsets();
+ const { pendingCount, isSyncing, refresh } = useSyncQueue();
+
+ const pendingFiles = useMemo(() => {
+ return localFileRegistry.getAll().filter(
+ (entry) => !entry.backendFileId && entry.syncStatus === 'local'
+ );
+ }, []);
+
+ 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 renderItem = useCallback(({ item }: { item: LocalFileEntry }) => (
+
+
+
+ {item.name}
+
+ {formatSize(item.size)}
+ {item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''}
+
+
+
+
+
+
+ ), []);
+
+ return (
+
+ {pendingCount > 0 && (
+
+ {isSyncing ? (
+
+ ) : (
+
+ )}
+
+ {isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`}
+
+
+ )}
+
+ {pendingFiles.length === 0 ? (
+
+
+ Tout est synchronisé
+
+ Aucun fichier en attente d'upload
+
+
+ ) : (
+ item.id}
+ renderItem={renderItem}
+ contentContainerStyle={styles.list}
+ ListHeaderComponent={
+
+ {pendingFiles.length} fichier{pendingFiles.length > 1 ? 's' : ''} en attente
+
+ }
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ 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,
+ },
+ headerText: {
+ fontSize: 14,
+ color: '#666',
+ marginBottom: 12,
+ },
+ fileRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#fff',
+ borderRadius: 10,
+ padding: 12,
+ marginBottom: 8,
+ gap: 12,
+ },
+ fileInfo: {
+ flex: 1,
+ },
+ fileName: {
+ fontSize: 15,
+ color: '#333',
+ fontWeight: '500',
+ },
+ fileMeta: {
+ fontSize: 12,
+ color: '#999',
+ marginTop: 2,
+ },
+ localBadge: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: '#f5f5f5',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ empty: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 12,
+ },
+ emptyTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#333',
+ },
+ emptySubtitle: {
+ fontSize: 14,
+ color: '#999',
+ },
+});
diff --git a/mobile/components/SettingsModal.tsx b/mobile/components/SettingsModal.tsx
new file mode 100644
index 0000000..7ffa07c
--- /dev/null
+++ b/mobile/components/SettingsModal.tsx
@@ -0,0 +1,500 @@
+import React, { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ Modal,
+ ScrollView,
+ Alert,
+} from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import { StoredFolder, SyncMode } from '../services/safDirectory';
+
+type SettingsView = 'menu' | 'folders' | 'sync';
+
+interface SettingsModalProps {
+ visible: boolean;
+ onClose: () => void;
+ folders: StoredFolder[];
+ onToggleVisibility: (folderId: string) => void;
+ onRemoveFolder: (folderId: string) => void;
+ onAddFolder: () => void;
+ onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
+ onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
+}
+
+const SYNC_MODES: { value: SyncMode; label: string; icon: string; color: string }[] = [
+ { value: 'none', label: 'Aucun', icon: 'sync-disabled', color: '#999' },
+ { value: 'manual', label: 'Manuel', icon: 'sync', color: '#1976D2' },
+ { value: 'auto', label: 'Auto', icon: 'sync-problem', color: '#43A047' },
+];
+
+export function SettingsModal({
+ visible,
+ onClose,
+ folders,
+ onToggleVisibility,
+ onRemoveFolder,
+ onAddFolder,
+ onUpdateSyncMode,
+ onUpdateSyncCellular,
+}: SettingsModalProps) {
+ const [view, setView] = useState('menu');
+
+ const handleClose = useCallback(() => {
+ setView('menu');
+ onClose();
+ }, [onClose]);
+
+ const handleRemoveFolder = useCallback(
+ (folder: StoredFolder) => {
+ Alert.alert(
+ 'Supprimer le dossier',
+ 'Le dossier sera retiré de la liste. Les fichiers resteront sur votre appareil.',
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Supprimer',
+ style: 'destructive',
+ onPress: () => onRemoveFolder(folder.id),
+ },
+ ]
+ );
+ },
+ [onRemoveFolder]
+ );
+
+ return (
+
+
+ {}}>
+
+
+ {view === 'menu' && (
+ setView(v)}
+ onClose={handleClose}
+ />
+ )}
+
+ {view === 'folders' && (
+ setView('menu')}
+ onToggleVisibility={onToggleVisibility}
+ onRemoveFolder={handleRemoveFolder}
+ onAddFolder={onAddFolder}
+ />
+ )}
+
+ {view === 'sync' && (
+ setView('menu')}
+ onUpdateSyncMode={onUpdateSyncMode}
+ onUpdateSyncCellular={onUpdateSyncCellular}
+ />
+ )}
+
+
+
+ );
+}
+
+function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void; onClose: () => void }) {
+ return (
+
+ Paramètres
+
+ onSelect('folders')}>
+
+
+
+
+ Dossiers
+ Gérer les dossiers affichés
+
+
+
+
+ onSelect('sync')}>
+
+
+
+
+ Synchronisation
+ Configurer l'upload automatique
+
+
+
+
+
+ Fermer
+
+
+ );
+}
+
+function FoldersView({
+ folders,
+ onBack,
+ onToggleVisibility,
+ onRemoveFolder,
+ onAddFolder,
+}: {
+ folders: StoredFolder[];
+ onBack: () => void;
+ onToggleVisibility: (id: string) => void;
+ onRemoveFolder: (folder: StoredFolder) => void;
+ onAddFolder: () => void;
+}) {
+ return (
+
+
+
+
+
+ Dossiers
+
+
+ {folders.length === 0 ? (
+ Aucun dossier configuré
+ ) : (
+ folders.map((folder) => (
+
+
+
+ {folder.name}
+
+ onToggleVisibility(folder.id)}
+ style={styles.actionBtn}
+ >
+
+
+ onRemoveFolder(folder)}
+ style={styles.actionBtn}
+ >
+
+
+
+ ))
+ )}
+
+
+
+ Ajouter un dossier
+
+
+ );
+}
+
+function SyncView({
+ folders,
+ onBack,
+ onUpdateSyncMode,
+ onUpdateSyncCellular,
+}: {
+ folders: StoredFolder[];
+ onBack: () => void;
+ onUpdateSyncMode: (id: string, mode: SyncMode) => void;
+ onUpdateSyncCellular: (id: string, enabled: boolean) => void;
+}) {
+ return (
+
+
+
+
+
+ Synchronisation
+
+
+
+ Configurez comment les fichiers de chaque dossier sont envoyés au serveur.
+
+
+ {folders.length === 0 ? (
+ Aucun dossier configuré
+ ) : (
+ folders.map((folder) => (
+
+
+
+
+ {folder.name}
+
+
+
+
+ {SYNC_MODES.map((mode) => (
+ onUpdateSyncMode(folder.id, mode.value)}
+ >
+
+
+ {mode.label}
+
+
+ ))}
+
+
+ {folder.syncMode !== 'none' && (
+
+
+ Réseau cellulaire
+ onUpdateSyncCellular(folder.id, !folder.syncCellular)}
+ >
+
+
+
+ )}
+
+ ))
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ container: {
+ backgroundColor: '#fff',
+ borderRadius: 16,
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: 20,
+ width: '85%',
+ maxHeight: '80%',
+ },
+ handle: {
+ width: 36,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: '#ddd',
+ alignSelf: 'center',
+ marginBottom: 16,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#333',
+ marginBottom: 16,
+ },
+ menuIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: 10,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ menuItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 14,
+ gap: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+ },
+ menuTextContainer: {
+ flex: 1,
+ },
+ menuLabel: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ },
+ menuDescription: {
+ fontSize: 13,
+ color: '#999',
+ marginTop: 2,
+ },
+ closeBtn: {
+ marginTop: 16,
+ alignItems: 'center',
+ paddingVertical: 10,
+ },
+ closeBtnText: {
+ fontSize: 15,
+ color: '#666',
+ },
+ viewContainer: {
+ maxHeight: 500,
+ },
+ viewHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ marginBottom: 8,
+ },
+ backBtn: {
+ padding: 4,
+ },
+ emptyText: {
+ fontSize: 14,
+ color: '#999',
+ textAlign: 'center',
+ marginVertical: 20,
+ },
+ folderRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ gap: 10,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+ },
+ folderName: {
+ flex: 1,
+ fontSize: 15,
+ color: '#333',
+ },
+ actionBtn: {
+ padding: 6,
+ },
+ addBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#F57C00',
+ borderRadius: 10,
+ paddingVertical: 12,
+ marginTop: 16,
+ gap: 8,
+ },
+ addBtnText: {
+ fontSize: 15,
+ color: '#fff',
+ fontWeight: '600',
+ },
+ syncInfo: {
+ fontSize: 13,
+ color: '#666',
+ marginBottom: 16,
+ lineHeight: 18,
+ },
+ syncFolderCard: {
+ backgroundColor: '#fafafa',
+ borderRadius: 10,
+ padding: 12,
+ marginBottom: 10,
+ },
+ syncFolderHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 10,
+ },
+ syncFolderName: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#333',
+ flex: 1,
+ },
+ radioGroup: {
+ flexDirection: 'row',
+ gap: 6,
+ marginBottom: 10,
+ },
+ radioBtn: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 8,
+ paddingHorizontal: 6,
+ borderRadius: 8,
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ gap: 4,
+ },
+ radioBtnActive: {
+ borderWidth: 1.5,
+ },
+ radioLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#999',
+ },
+ cellularRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingTop: 6,
+ borderTopWidth: 1,
+ borderTopColor: '#e8e8e8',
+ },
+ cellularLabel: {
+ flex: 1,
+ fontSize: 13,
+ color: '#666',
+ },
+ toggleBtn: {
+ width: 44,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: '#ddd',
+ justifyContent: 'center',
+ paddingHorizontal: 2,
+ },
+ toggleBtnActive: {
+ backgroundColor: '#1976D2',
+ },
+ toggleDot: {
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ backgroundColor: '#fff',
+ },
+ toggleDotActive: {
+ alignSelf: 'flex-end',
+ },
+});
diff --git a/mobile/components/SyncStatusIcon.tsx b/mobile/components/SyncStatusIcon.tsx
new file mode 100644
index 0000000..3994f3c
--- /dev/null
+++ b/mobile/components/SyncStatusIcon.tsx
@@ -0,0 +1,85 @@
+import React, { useEffect } from 'react';
+import { TouchableOpacity, Text, StyleSheet, View } from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ withRepeat,
+ withSequence,
+ Easing,
+ cancelAnimation,
+} from 'react-native-reanimated';
+
+interface SyncStatusIconProps {
+ isSyncing: boolean;
+ pendingCount: number;
+ onPress: () => void;
+}
+
+export function SyncStatusIcon({ isSyncing, pendingCount, onPress }: SyncStatusIconProps) {
+ const rotation = useSharedValue(0);
+
+ useEffect(() => {
+ if (isSyncing) {
+ rotation.value = withRepeat(
+ withSequence(
+ withTiming(360, { duration: 1000, easing: Easing.linear }),
+ withTiming(0, { duration: 0 })
+ ),
+ -1
+ );
+ } else {
+ cancelAnimation(rotation);
+ rotation.value = withTiming(0, { duration: 200 });
+ }
+ }, [isSyncing, rotation]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${rotation.value}deg` }],
+ }));
+
+ return (
+
+
+ 0 ? '#F57C00' : '#666'}
+ />
+
+ {pendingCount > 0 && !isSyncing && (
+
+
+ {pendingCount > 99 ? '99+' : pendingCount}
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'relative',
+ marginRight: 4,
+ padding: 8,
+ },
+ badge: {
+ position: 'absolute',
+ top: 2,
+ right: 0,
+ backgroundColor: '#E53935',
+ borderRadius: 8,
+ minWidth: 16,
+ height: 16,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingHorizontal: 4,
+ },
+ badgeText: {
+ fontSize: 9,
+ fontWeight: '700',
+ color: '#fff',
+ },
+});
diff --git a/mobile/hooks/useAutoSync.ts b/mobile/hooks/useAutoSync.ts
new file mode 100644
index 0000000..ca2eace
--- /dev/null
+++ b/mobile/hooks/useAutoSync.ts
@@ -0,0 +1,125 @@
+import { useEffect, useCallback, useRef } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { File, UploadType } from 'expo-file-system';
+import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
+import { safDirectory, StoredFolder } from '../services/safDirectory';
+import { localFileRegistry } from '../services/localFileRegistry';
+import { apiClient } from '../api/client';
+import { API_BASE_URL, ENDPOINTS } from '../constants/api';
+import { ApiError } from '../types';
+import { setIsSyncing } from './useSyncQueue';
+
+function canSyncFolder(folder: StoredFolder, netInfo: NetInfoState): boolean {
+ if (folder.syncMode !== 'auto') return false;
+ if (!netInfo.isConnected) return false;
+ if (!folder.syncCellular && netInfo.type !== 'wifi') return false;
+ return true;
+}
+
+interface UploadResult {
+ name: string;
+ id: string;
+}
+
+async function uploadFile(file: { uri: string; type: string; name: string }): Promise {
+ const fsFile = new File(file.uri);
+ const headers: Record = {};
+ const token = apiClient.getAccessToken();
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+ const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
+ httpMethod: 'POST',
+ uploadType: UploadType.MULTIPART,
+ fieldName: 'file',
+ mimeType: file.type,
+ headers,
+ });
+
+ if (result.status >= 400) {
+ let message = 'Upload failed';
+ try {
+ const body: ApiError = JSON.parse(result.body);
+ message = body.error?.message || message;
+ } catch {
+ message = result.body || message;
+ }
+ throw new Error(message);
+ }
+
+ const body = JSON.parse(result.body);
+ const items = body.data ?? body;
+ const item = Array.isArray(items) ? items[0] : items;
+ return item as UploadResult;
+}
+
+export function useAutoSync() {
+ const queryClient = useQueryClient();
+ const isRunning = useRef(false);
+
+ const checkAndSync = useCallback(async () => {
+ if (isRunning.current) return;
+ isRunning.current = true;
+
+ try {
+ const folders = safDirectory.getAll();
+ const autoFolders = folders.filter((f) => f.syncMode === 'auto');
+ if (autoFolders.length === 0) return;
+
+ const netInfo = await NetInfo.fetch();
+ const eligible = autoFolders.filter((f) => canSyncFolder(f, netInfo));
+ if (eligible.length === 0) return;
+
+ const eligibleIds = new Set(eligible.map((f) => f.id));
+ const registry = localFileRegistry.getAll();
+
+ const pendingFiles = registry.filter(
+ (entry) =>
+ !entry.backendFileId &&
+ entry.syncStatus === 'local' &&
+ entry.folderId &&
+ eligibleIds.has(entry.folderId) &&
+ entry.localUri
+ );
+
+ if (pendingFiles.length === 0) return;
+
+ console.log(`[useAutoSync] upload de ${pendingFiles.length} fichier(s)`);
+ setIsSyncing(true);
+
+ for (const entry of pendingFiles) {
+ try {
+ const uploaded = await uploadFile({
+ uri: entry.localUri,
+ type: entry.mimeType,
+ name: entry.name,
+ });
+
+ localFileRegistry.register({
+ ...entry,
+ backendFileId: uploaded.id,
+ syncStatus: 'synced',
+ });
+
+ console.log(`[useAutoSync] "${entry.name}" uploadé → id=${uploaded.id}`);
+ } catch (err) {
+ console.error(`[useAutoSync] échec upload "${entry.name}":`, err);
+ }
+ }
+
+ queryClient.invalidateQueries({ queryKey: ['files'] });
+ console.log(`[useAutoSync] sync terminé`);
+ } finally {
+ setIsSyncing(false);
+ isRunning.current = false;
+ }
+ }, [queryClient]);
+
+ useEffect(() => {
+ const interval = setInterval(checkAndSync, 30_000);
+ checkAndSync();
+ return () => clearInterval(interval);
+ }, [checkAndSync]);
+
+ return { triggerSync: checkAndSync };
+}
diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts
index ae1199f..82a98cd 100644
--- a/mobile/hooks/useDeviceFiles.ts
+++ b/mobile/hooks/useDeviceFiles.ts
@@ -180,7 +180,7 @@ export function useDeviceFiles() {
safDirectory.addFolder(dirUri, dirName);
setFolders(safDirectory.getAll());
- const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true });
+ const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false });
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts
index 8e8b345..cb892af 100644
--- a/mobile/hooks/useLocalFiles.ts
+++ b/mobile/hooks/useLocalFiles.ts
@@ -25,6 +25,7 @@ export function useLocalFiles() {
size: df.size,
syncStatus: 'local',
createdAt: df.createdAt,
+ folderId: df.folderId,
});
}
}
diff --git a/mobile/hooks/useSyncQueue.ts b/mobile/hooks/useSyncQueue.ts
new file mode 100644
index 0000000..d45ced9
--- /dev/null
+++ b/mobile/hooks/useSyncQueue.ts
@@ -0,0 +1,54 @@
+import { useState, useEffect, useCallback } from 'react';
+import { createMMKV } from 'react-native-mmkv';
+import { localFileRegistry } from '../services/localFileRegistry';
+import { safDirectory } from '../services/safDirectory';
+import { useDeviceFiles } from './useDeviceFiles';
+
+const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
+
+export function getIsSyncing(): boolean {
+ return syncStateStorage.getString('is_syncing') === 'true';
+}
+
+export function setIsSyncing(value: boolean) {
+ syncStateStorage.set('is_syncing', value ? 'true' : 'false');
+}
+
+export function useSyncQueue() {
+ const [pendingCount, setPendingCount] = useState(0);
+ const [isSyncing, setIsSyncingState] = useState(() => getIsSyncing());
+ const { folders } = useDeviceFiles();
+
+ const refresh = useCallback(() => {
+ const allFolders = safDirectory.getAll();
+ const syncFolderIds = new Set(
+ allFolders.filter((f) => f.syncMode !== 'none').map((f) => f.id)
+ );
+
+ if (syncFolderIds.size === 0) {
+ setPendingCount(0);
+ setIsSyncingState(getIsSyncing());
+ return;
+ }
+
+ const registry = localFileRegistry.getAll();
+ let count = 0;
+ for (const entry of registry) {
+ if (entry.backendFileId) continue;
+ if (entry.syncStatus !== 'local') continue;
+ if (entry.folderId && !syncFolderIds.has(entry.folderId)) continue;
+ count++;
+ }
+
+ setPendingCount(count);
+ setIsSyncingState(getIsSyncing());
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ const interval = setInterval(refresh, 5000);
+ return () => clearInterval(interval);
+ }, [refresh]);
+
+ return { pendingCount, isSyncing, refresh };
+}
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index bf386f1..64e48ec 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.8",
"@react-navigation/native-stack": "^7.17.10",
"@tanstack/react-query": "^5.101.2",
@@ -1625,6 +1626,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@react-native-community/netinfo": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz",
+ "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": ">=0.59"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.86.0",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 0c31310..9181dae 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -4,6 +4,7 @@
"main": "index.ts",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
+ "@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.8",
"@react-navigation/native-stack": "^7.17.10",
"@tanstack/react-query": "^5.101.2",
diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts
index e6bd1be..86eb8c0 100644
--- a/mobile/services/safDirectory.ts
+++ b/mobile/services/safDirectory.ts
@@ -1,10 +1,14 @@
import { createMMKV } from 'react-native-mmkv';
+export type SyncMode = 'none' | 'manual' | 'auto';
+
export type StoredFolder = {
id: string;
uri: string;
name: string;
visible: boolean;
+ syncMode: SyncMode;
+ syncCellular: boolean;
};
const storage = createMMKV({ id: 'vaultdrop-saf' });
@@ -18,7 +22,20 @@ function generateId(): string {
function getAllRaw(): StoredFolder[] {
const raw = storage.getString(FOLDERS_KEY);
if (!raw) return [];
- return JSON.parse(raw) as StoredFolder[];
+ const parsed = JSON.parse(raw) as StoredFolder[];
+ let migrated = false;
+ for (const f of parsed) {
+ if (f.syncMode === undefined) {
+ (f as any).syncMode = 'none';
+ migrated = true;
+ }
+ if (f.syncCellular === undefined) {
+ (f as any).syncCellular = false;
+ migrated = true;
+ }
+ }
+ if (migrated) saveAll(parsed);
+ return parsed;
}
function saveAll(folders: StoredFolder[]) {
@@ -39,7 +56,7 @@ export const safDirectory = {
if (folders.some((f) => f.uri === uri)) {
return folders.find((f) => f.uri === uri)!;
}
- const folder: StoredFolder = { id: generateId(), uri, name, visible: true };
+ const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false };
folders.push(folder);
saveAll(folders);
return folder;
@@ -57,6 +74,20 @@ export const safDirectory = {
saveAll(folders);
},
+ updateSyncMode(id: string, syncMode: SyncMode) {
+ const folders = getAllRaw().map((f) =>
+ f.id === id ? { ...f, syncMode } : f
+ );
+ saveAll(folders);
+ },
+
+ updateSyncCellular(id: string, syncCellular: boolean) {
+ const folders = getAllRaw().map((f) =>
+ f.id === id ? { ...f, syncCellular } : f
+ );
+ saveAll(folders);
+ },
+
clear() {
storage.remove(FOLDERS_KEY);
},
diff --git a/mobile/types/index.ts b/mobile/types/index.ts
index 70646b1..7a15f0a 100644
--- a/mobile/types/index.ts
+++ b/mobile/types/index.ts
@@ -129,4 +129,5 @@ export interface LocalFileEntry {
syncStatus: SyncStatus;
createdAt: string;
tags?: Tag[];
+ folderId?: string;
}