sync files
This commit is contained in:
@@ -18,6 +18,7 @@ import { FileDetailScreen } from './app/file-detail';
|
|||||||
import { FileEditScreen } from './app/file-edit';
|
import { FileEditScreen } from './app/file-edit';
|
||||||
import { FolderScreen } from './app/folder';
|
import { FolderScreen } from './app/folder';
|
||||||
import { OnboardingScreen } from './app/onboarding';
|
import { OnboardingScreen } from './app/onboarding';
|
||||||
|
import { SyncDetailScreen } from './app/sync-detail';
|
||||||
import { onboardingStorage } from './services/onboardingStorage';
|
import { onboardingStorage } from './services/onboardingStorage';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
@@ -66,6 +67,7 @@ function AppNavigator() {
|
|||||||
/>
|
/>
|
||||||
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
|
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
|
||||||
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
|
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
|
||||||
|
<Stack.Screen name="SyncDetail" component={SyncDetailScreen} options={{ title: 'Synchronisation' }} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+38
-107
@@ -10,7 +10,11 @@ import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
|||||||
import { FileItem, isFolder } from '../types';
|
import { FileItem, isFolder } from '../types';
|
||||||
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
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 NUM_COLUMNS = 3;
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
@@ -23,6 +27,7 @@ type RootStackParamList = {
|
|||||||
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
|
FileDetail: { fileIds: string[]; initialIndex: number; deviceFiles?: Record<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
|
||||||
FileEdit: { fileIds: string[] };
|
FileEdit: { fileIds: string[] };
|
||||||
Folder: { folderId: string; folderName: string };
|
Folder: { folderId: string; folderName: string };
|
||||||
|
SyncDetail: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
@@ -149,7 +154,9 @@ export function HomeScreen() {
|
|||||||
const moveFiles = useMoveFiles();
|
const moveFiles = useMoveFiles();
|
||||||
const { data: foldersData } = useFolders();
|
const { data: foldersData } = useFolders();
|
||||||
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
||||||
const [folderSheetVisible, setFolderSheetVisible] = useState(false);
|
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
||||||
|
const { pendingCount, isSyncing } = useSyncQueue();
|
||||||
|
useAutoSync();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
@@ -160,12 +167,19 @@ export function HomeScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<SyncStatusIcon
|
||||||
|
isSyncing={isSyncing}
|
||||||
|
pendingCount={pendingCount}
|
||||||
|
onPress={() => navigation.navigate('SyncDetail')}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setSettingsModalVisible(true)} style={{ marginRight: 4, padding: 8 }}>
|
||||||
<MaterialIcons name="settings" size={22} color="#666" />
|
<MaterialIcons name="settings" size={22} color="#666" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation, pendingCount, isSyncing]);
|
||||||
|
|
||||||
const selectionMode = selectedIds.size > 0;
|
const selectionMode = selectedIds.size > 0;
|
||||||
|
|
||||||
@@ -305,10 +319,20 @@ export function HomeScreen() {
|
|||||||
}, [refreshFolders]);
|
}, [refreshFolders]);
|
||||||
|
|
||||||
const handleAddFolder = useCallback(async () => {
|
const handleAddFolder = useCallback(async () => {
|
||||||
setFolderSheetVisible(false);
|
setSettingsModalVisible(false);
|
||||||
await pickDirectory();
|
await pickDirectory();
|
||||||
}, [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) => {
|
const handleItemPress = useCallback((file: UnifiedFileItem) => {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
toggleSelection(file.id);
|
toggleSelection(file.id);
|
||||||
@@ -584,44 +608,16 @@ export function HomeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal visible={folderSheetVisible} transparent animationType="slide" onRequestClose={() => setFolderSheetVisible(false)}>
|
<SettingsModal
|
||||||
<TouchableOpacity style={styles.bottomSheetOverlay} activeOpacity={1} onPress={() => setFolderSheetVisible(false)}>
|
visible={settingsModalVisible}
|
||||||
<TouchableOpacity activeOpacity={1} style={styles.bottomSheet} onPress={() => {}}>
|
onClose={() => setSettingsModalVisible(false)}
|
||||||
<View style={styles.bottomSheetHandle} />
|
folders={folders}
|
||||||
<Text style={styles.bottomSheetTitle}>Mes dossiers</Text>
|
onToggleVisibility={handleToggleFolderVisibility}
|
||||||
{folders.length === 0 ? (
|
onRemoveFolder={handleRemoveFolder}
|
||||||
<Text style={styles.emptyFoldersText}>Aucun dossier configuré</Text>
|
onAddFolder={handleAddFolder}
|
||||||
) : (
|
onUpdateSyncMode={handleUpdateSyncMode}
|
||||||
folders.map((folder) => (
|
onUpdateSyncCellular={handleUpdateSyncCellular}
|
||||||
<View key={folder.id} style={styles.folderRow}>
|
|
||||||
<MaterialIcons name="folder" size={20} color="#F57C00" />
|
|
||||||
<Text style={styles.folderRowName} numberOfLines={1}>{folder.name}</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleToggleFolderVisibility(folder.id)}
|
|
||||||
style={styles.folderRowAction}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={folder.visible ? 'visibility' : 'visibility-off'}
|
|
||||||
size={20}
|
|
||||||
color={folder.visible ? '#1976D2' : '#999'}
|
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleRemoveFolder(folder.id)}
|
|
||||||
style={styles.folderRowAction}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<TouchableOpacity style={styles.addFolderBtn} onPress={handleAddFolder}>
|
|
||||||
<MaterialIcons name="add" size={20} color="#fff" />
|
|
||||||
<Text style={styles.addFolderBtnText}>Ajouter un dossier</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -884,69 +880,4 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontWeight: '600',
|
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function OnboardingScreen() {
|
|||||||
|
|
||||||
const complete = useCallback(() => {
|
const complete = useCallback(() => {
|
||||||
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
|
onboardingStorage.setCompletedVersion(CURRENT_ONBOARDING_VERSION);
|
||||||
navigation.navigate('Home' as never);
|
navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] });
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const handlePickDirectory = useCallback(async () => {
|
const handlePickDirectory = useCallback(async () => {
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<View style={styles.fileRow}>
|
||||||
|
<MaterialIcons name="insert-drive-file" size={20} color="#999" />
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>{item.name}</Text>
|
||||||
|
<Text style={styles.fileMeta}>
|
||||||
|
{formatSize(item.size)}
|
||||||
|
{item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.localBadge}>
|
||||||
|
<MaterialIcons name="phone-android" size={14} color="#757575" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
|
||||||
|
onPress={handleSyncAll}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons name="cloud-upload" size={20} color="#fff" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.syncBtnText}>
|
||||||
|
{isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingFiles.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
|
||||||
|
<Text style={styles.emptyTitle}>Tout est synchronisé</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Aucun fichier en attente d'upload
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={pendingFiles}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text style={styles.headerText}>
|
||||||
|
{pendingFiles.length} fichier{pendingFiles.length > 1 ? 's' : ''} en attente
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<SettingsView>('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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.overlay}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={handleClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
|
||||||
|
{view === 'menu' && (
|
||||||
|
<MenuView
|
||||||
|
onSelect={(v) => setView(v)}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'folders' && (
|
||||||
|
<FoldersView
|
||||||
|
folders={folders}
|
||||||
|
onBack={() => setView('menu')}
|
||||||
|
onToggleVisibility={onToggleVisibility}
|
||||||
|
onRemoveFolder={handleRemoveFolder}
|
||||||
|
onAddFolder={onAddFolder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'sync' && (
|
||||||
|
<SyncView
|
||||||
|
folders={folders}
|
||||||
|
onBack={() => setView('menu')}
|
||||||
|
onUpdateSyncMode={onUpdateSyncMode}
|
||||||
|
onUpdateSyncCellular={onUpdateSyncCellular}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.title}>Paramètres</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('folders')}>
|
||||||
|
<View style={[styles.menuIcon, { backgroundColor: '#FFF3E0' }]}>
|
||||||
|
<MaterialIcons name="folder" size={22} color="#F57C00" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.menuTextContainer}>
|
||||||
|
<Text style={styles.menuLabel}>Dossiers</Text>
|
||||||
|
<Text style={styles.menuDescription}>Gérer les dossiers affichés</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.menuItem} onPress={() => onSelect('sync')}>
|
||||||
|
<View style={[styles.menuIcon, { backgroundColor: '#E3F2FD' }]}>
|
||||||
|
<MaterialIcons name="cloud-sync" size={22} color="#1976D2" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.menuTextContainer}>
|
||||||
|
<Text style={styles.menuLabel}>Synchronisation</Text>
|
||||||
|
<Text style={styles.menuDescription}>Configurer l'upload automatique</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="chevron-right" size={24} color="#ccc" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||||
|
<Text style={styles.closeBtnText}>Fermer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FoldersView({
|
||||||
|
folders,
|
||||||
|
onBack,
|
||||||
|
onToggleVisibility,
|
||||||
|
onRemoveFolder,
|
||||||
|
onAddFolder,
|
||||||
|
}: {
|
||||||
|
folders: StoredFolder[];
|
||||||
|
onBack: () => void;
|
||||||
|
onToggleVisibility: (id: string) => void;
|
||||||
|
onRemoveFolder: (folder: StoredFolder) => void;
|
||||||
|
onAddFolder: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.viewHeader}>
|
||||||
|
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||||
|
<MaterialIcons name="arrow-back" size={22} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title}>Dossiers</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
||||||
|
) : (
|
||||||
|
folders.map((folder) => (
|
||||||
|
<View key={folder.id} style={styles.folderRow}>
|
||||||
|
<MaterialIcons name="folder" size={20} color="#F57C00" />
|
||||||
|
<Text style={styles.folderName} numberOfLines={1}>
|
||||||
|
{folder.name}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onToggleVisibility(folder.id)}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={folder.visible ? 'visibility' : 'visibility-off'}
|
||||||
|
size={20}
|
||||||
|
color={folder.visible ? '#1976D2' : '#999'}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemoveFolder(folder)}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.addBtn} onPress={onAddFolder}>
|
||||||
|
<MaterialIcons name="add" size={20} color="#fff" />
|
||||||
|
<Text style={styles.addBtnText}>Ajouter un dossier</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncView({
|
||||||
|
folders,
|
||||||
|
onBack,
|
||||||
|
onUpdateSyncMode,
|
||||||
|
onUpdateSyncCellular,
|
||||||
|
}: {
|
||||||
|
folders: StoredFolder[];
|
||||||
|
onBack: () => void;
|
||||||
|
onUpdateSyncMode: (id: string, mode: SyncMode) => void;
|
||||||
|
onUpdateSyncCellular: (id: string, enabled: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.viewHeader}>
|
||||||
|
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||||
|
<MaterialIcons name="arrow-back" size={22} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title}>Synchronisation</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.syncInfo}>
|
||||||
|
Configurez comment les fichiers de chaque dossier sont envoyés au serveur.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
||||||
|
) : (
|
||||||
|
folders.map((folder) => (
|
||||||
|
<View key={folder.id} style={styles.syncFolderCard}>
|
||||||
|
<View style={styles.syncFolderHeader}>
|
||||||
|
<MaterialIcons name="folder" size={18} color="#F57C00" />
|
||||||
|
<Text style={styles.syncFolderName} numberOfLines={1}>
|
||||||
|
{folder.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.radioGroup}>
|
||||||
|
{SYNC_MODES.map((mode) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={mode.value}
|
||||||
|
style={[
|
||||||
|
styles.radioBtn,
|
||||||
|
folder.syncMode === mode.value && styles.radioBtnActive,
|
||||||
|
]}
|
||||||
|
onPress={() => onUpdateSyncMode(folder.id, mode.value)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={
|
||||||
|
folder.syncMode === mode.value
|
||||||
|
? 'radio-button-checked'
|
||||||
|
: 'radio-button-unchecked'
|
||||||
|
}
|
||||||
|
size={18}
|
||||||
|
color={folder.syncMode === mode.value ? mode.color : '#999'}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.radioLabel,
|
||||||
|
folder.syncMode === mode.value && { color: mode.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{folder.syncMode !== 'none' && (
|
||||||
|
<View style={styles.cellularRow}>
|
||||||
|
<MaterialIcons name="cell-tower" size={18} color="#666" />
|
||||||
|
<Text style={styles.cellularLabel}>Réseau cellulaire</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.toggleBtn,
|
||||||
|
folder.syncCellular && styles.toggleBtnActive,
|
||||||
|
]}
|
||||||
|
onPress={() => onUpdateSyncCellular(folder.id, !folder.syncCellular)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.toggleDot,
|
||||||
|
folder.syncCellular && styles.toggleDotActive,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<TouchableOpacity onPress={onPress} style={styles.container}>
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="sync"
|
||||||
|
size={22}
|
||||||
|
color={isSyncing ? '#1976D2' : pendingCount > 0 ? '#F57C00' : '#666'}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
{pendingCount > 0 && !isSyncing && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{pendingCount > 99 ? '99+' : pendingCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<UploadResult> {
|
||||||
|
const fsFile = new File(file.uri);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ export function useDeviceFiles() {
|
|||||||
safDirectory.addFolder(dirUri, dirName);
|
safDirectory.addFolder(dirUri, dirName);
|
||||||
setFolders(safDirectory.getAll());
|
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) => {
|
setFiles((prev) => {
|
||||||
const existing = new Set(prev.map((f) => f.id));
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
const merged = [...prev];
|
const merged = [...prev];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function useLocalFiles() {
|
|||||||
size: df.size,
|
size: df.size,
|
||||||
syncStatus: 'local',
|
syncStatus: 'local',
|
||||||
createdAt: df.createdAt,
|
createdAt: df.createdAt,
|
||||||
|
folderId: df.folderId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Generated
+11
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-community/netinfo": "12.0.1",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.8",
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
"@react-navigation/native-stack": "^7.17.10",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
@@ -1625,6 +1626,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.86.0",
|
"version": "0.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-community/netinfo": "12.0.1",
|
||||||
"@react-navigation/native": "^7.3.8",
|
"@react-navigation/native": "^7.3.8",
|
||||||
"@react-navigation/native-stack": "^7.17.10",
|
"@react-navigation/native-stack": "^7.17.10",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createMMKV } from 'react-native-mmkv';
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
|
|
||||||
|
export type SyncMode = 'none' | 'manual' | 'auto';
|
||||||
|
|
||||||
export type StoredFolder = {
|
export type StoredFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
name: string;
|
name: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
syncMode: SyncMode;
|
||||||
|
syncCellular: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
||||||
@@ -18,7 +22,20 @@ function generateId(): string {
|
|||||||
function getAllRaw(): StoredFolder[] {
|
function getAllRaw(): StoredFolder[] {
|
||||||
const raw = storage.getString(FOLDERS_KEY);
|
const raw = storage.getString(FOLDERS_KEY);
|
||||||
if (!raw) return [];
|
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[]) {
|
function saveAll(folders: StoredFolder[]) {
|
||||||
@@ -39,7 +56,7 @@ export const safDirectory = {
|
|||||||
if (folders.some((f) => f.uri === uri)) {
|
if (folders.some((f) => f.uri === uri)) {
|
||||||
return folders.find((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);
|
folders.push(folder);
|
||||||
saveAll(folders);
|
saveAll(folders);
|
||||||
return folder;
|
return folder;
|
||||||
@@ -57,6 +74,20 @@ export const safDirectory = {
|
|||||||
saveAll(folders);
|
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() {
|
clear() {
|
||||||
storage.remove(FOLDERS_KEY);
|
storage.remove(FOLDERS_KEY);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,4 +129,5 @@ export interface LocalFileEntry {
|
|||||||
syncStatus: SyncStatus;
|
syncStatus: SyncStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
folderId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user