sync files

This commit is contained in:
m
2026-07-27 21:44:57 +02:00
parent 5c85ffa5fe
commit cb9cea6ff3
14 changed files with 1032 additions and 114 deletions
+2
View File
@@ -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() {
/>
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
<Stack.Screen name="SyncDetail" component={SyncDetailScreen} options={{ title: 'Synchronisation' }} />
</>
) : (
<>
+41 -110
View File
@@ -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<string, { localUri: string; name: string; mimeType: string; createdAt: string }> };
FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string };
SyncDetail: undefined;
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
@@ -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: () => (
<TouchableOpacity onPress={() => setFolderSheetVisible(true)} style={{ marginRight: 4, padding: 8 }}>
<MaterialIcons name="settings" size={22} color="#666" />
</TouchableOpacity>
<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" />
</TouchableOpacity>
</View>
),
});
}, [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() {
</TouchableOpacity>
</Modal>
<Modal visible={folderSheetVisible} transparent animationType="slide" onRequestClose={() => setFolderSheetVisible(false)}>
<TouchableOpacity style={styles.bottomSheetOverlay} activeOpacity={1} onPress={() => setFolderSheetVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.bottomSheet} onPress={() => {}}>
<View style={styles.bottomSheetHandle} />
<Text style={styles.bottomSheetTitle}>Mes dossiers</Text>
{folders.length === 0 ? (
<Text style={styles.emptyFoldersText}>Aucun dossier configuré</Text>
) : (
folders.map((folder) => (
<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>
<SettingsModal
visible={settingsModalVisible}
onClose={() => setSettingsModalVisible(false)}
folders={folders}
onToggleVisibility={handleToggleFolderVisibility}
onRemoveFolder={handleRemoveFolder}
onAddFolder={handleAddFolder}
onUpdateSyncMode={handleUpdateSyncMode}
onUpdateSyncCellular={handleUpdateSyncCellular}
/>
</KeyboardAvoidingView>
);
}
@@ -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,
},
});
+1 -1
View File
@@ -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 () => {
+176
View File
@@ -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',
},
});
+500
View File
@@ -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',
},
});
+85
View File
@@ -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',
},
});
+125
View File
@@ -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 };
}
+1 -1
View File
@@ -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];
+1
View File
@@ -25,6 +25,7 @@ export function useLocalFiles() {
size: df.size,
syncStatus: 'local',
createdAt: df.createdAt,
folderId: df.folderId,
});
}
}
+54
View File
@@ -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 };
}
+11
View File
@@ -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",
+1
View File
@@ -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",
+33 -2
View File
@@ -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);
},
+1
View File
@@ -129,4 +129,5 @@ export interface LocalFileEntry {
syncStatus: SyncStatus;
createdAt: string;
tags?: Tag[];
folderId?: string;
}