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; }