background fetch
This commit is contained in:
@@ -118,10 +118,6 @@ export function HomeScreen() {
|
|||||||
const { pendingCount, isSyncing } = useSyncQueue();
|
const { pendingCount, isSyncing } = useSyncQueue();
|
||||||
useAutoSync();
|
useAutoSync();
|
||||||
|
|
||||||
const handleUploadComplete = useCallback(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const itemSize = (SCREEN_WIDTH - PADDING_H * 2 - (numColumns - 1) * ITEM_GAP) / numColumns;
|
const itemSize = (SCREEN_WIDTH - PADDING_H * 2 - (numColumns - 1) * ITEM_GAP) / numColumns;
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -659,7 +655,6 @@ export function HomeScreen() {
|
|||||||
<UploadModal
|
<UploadModal
|
||||||
visible={uploadModalVisible}
|
visible={uploadModalVisible}
|
||||||
onClose={() => setUploadModalVisible(false)}
|
onClose={() => setUploadModalVisible(false)}
|
||||||
onUploadComplete={handleUploadComplete}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
|
|||||||
+90
-20
@@ -5,23 +5,63 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
||||||
import { onboardingStorage } from '../services/onboardingStorage';
|
import { onboardingStorage } from '../services/onboardingStorage';
|
||||||
|
import { scanSubdirectories } from '../hooks/useDeviceFiles';
|
||||||
|
import { safDirectory } from '../services/safDirectory';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
const ICON_MAP: Record<string, keyof typeof MaterialIcons.glyphMap> = {
|
type ScanState = 'idle' | 'scanning' | 'done';
|
||||||
'waving-hand': 'waving-hand',
|
|
||||||
'folder-off': 'folder-off',
|
function stepIcon(step: OnboardingStep): keyof typeof MaterialIcons.glyphMap {
|
||||||
'create-new-folder': 'create-new-folder',
|
if (step.icon === 'waving-hand') return 'waving-hand';
|
||||||
};
|
if (step.icon === 'create-new-folder') return 'create-new-folder';
|
||||||
|
if (step.icon === 'check-circle') return 'check-circle';
|
||||||
|
if (step.icon === 'folder-off') return 'folder-off';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanProgressView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<ActivityIndicator size={48} color="#1976D2" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Scan en cours...</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Dot. explore les sous-dossiers de votre stockage. Cela peut prendre quelques secondes.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanDoneView({ folderCount }: { folderCount: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[styles.iconContainer, { backgroundColor: '#E8F5E9' }]}>
|
||||||
|
<MaterialIcons name="check-circle" size={64} color="#43A047" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Scan terminé !</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{folderCount > 1
|
||||||
|
? `${folderCount} dossiers découverts et ajoutés à votre espace Dot.`
|
||||||
|
: '1 dossier ajouté à votre espace Dot.'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function OnboardingScreen() {
|
export function OnboardingScreen() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [scanState, setScanState] = useState<ScanState>('idle');
|
||||||
|
const [folderCount, setFolderCount] = useState(0);
|
||||||
const pendingSteps = onboardingStorage.getPendingSteps();
|
const pendingSteps = onboardingStorage.getPendingSteps();
|
||||||
|
|
||||||
const step = pendingSteps[currentIndex];
|
const step = pendingSteps[currentIndex];
|
||||||
@@ -31,19 +71,33 @@ export function OnboardingScreen() {
|
|||||||
navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] });
|
navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] });
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const handlePickDirectory = useCallback(async () => {
|
const handleRecursiveScan = useCallback(async () => {
|
||||||
const { safDirectory } = await import('../services/safDirectory');
|
|
||||||
const FileSystem = await import('expo-file-system/legacy');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
||||||
if (!result.granted) return;
|
if (!result.granted) return;
|
||||||
|
|
||||||
const dirUri = result.directoryUri;
|
const dirUri = result.directoryUri;
|
||||||
const parts = dirUri.split('/');
|
const parts = dirUri.split('/');
|
||||||
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
|
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Stockage');
|
||||||
|
|
||||||
|
setScanState('scanning');
|
||||||
|
|
||||||
safDirectory.addFolder(dirUri, dirName);
|
safDirectory.addFolder(dirUri, dirName);
|
||||||
|
|
||||||
|
const subdirs = await scanSubdirectories(dirUri);
|
||||||
|
if (subdirs.length > 0) {
|
||||||
|
safDirectory.addBatchFolders(
|
||||||
|
subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolderCount(1 + subdirs.length);
|
||||||
|
setScanState('done');
|
||||||
|
|
||||||
|
safDirectory.setDiscovered();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Onboarding] pickDirectory error:', err);
|
console.error('[Onboarding] recursive scan error:', err);
|
||||||
|
setScanState('idle');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -53,6 +107,8 @@ export function OnboardingScreen() {
|
|||||||
|
|
||||||
if (currentIndex < pendingSteps.length - 1) {
|
if (currentIndex < pendingSteps.length - 1) {
|
||||||
setCurrentIndex(currentIndex + 1);
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
setScanState('idle');
|
||||||
|
setFolderCount(0);
|
||||||
} else {
|
} else {
|
||||||
complete();
|
complete();
|
||||||
}
|
}
|
||||||
@@ -67,6 +123,8 @@ export function OnboardingScreen() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isScanStep = step.action?.type === 'recursive_scan';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.skipContainer}>
|
<View style={styles.skipContainer}>
|
||||||
@@ -76,23 +134,26 @@ export function OnboardingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
|
{scanState === 'scanning' && isScanStep ? (
|
||||||
|
<ScanProgressView />
|
||||||
|
) : scanState === 'done' && isScanStep ? (
|
||||||
|
<ScanDoneView folderCount={folderCount} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
<MaterialIcons
|
<MaterialIcons name={stepIcon(step)} size={64} color="#1976D2" />
|
||||||
name={ICON_MAP[step.icon] ?? 'info'}
|
|
||||||
size={64}
|
|
||||||
color="#1976D2"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.title}>{step.title}</Text>
|
<Text style={styles.title}>{step.title}</Text>
|
||||||
<Text style={styles.description}>{step.description}</Text>
|
<Text style={styles.description}>{step.description}</Text>
|
||||||
|
|
||||||
{step.action?.type === 'pick_directory' && (
|
{isScanStep && scanState === 'idle' && (
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handlePickDirectory}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleRecursiveScan}>
|
||||||
<MaterialIcons name="folder-open" size={20} color="#fff" />
|
<MaterialIcons name="folder-open" size={20} color="#fff" />
|
||||||
<Text style={styles.actionBtnText}>{step.action.label}</Text>
|
<Text style={styles.actionBtnText}>{step.action?.label ?? 'Choisir un dossier'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
@@ -105,12 +166,21 @@ export function OnboardingScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{(!isScanStep || scanState === 'done') && (
|
||||||
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
|
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
|
||||||
<Text style={styles.nextBtnText}>
|
<Text style={styles.nextBtnText}>
|
||||||
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
|
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
|
||||||
</Text>
|
</Text>
|
||||||
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
|
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isScanStep && scanState === 'idle' && (
|
||||||
|
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
|
||||||
|
<Text style={styles.nextBtnText}>Passer cette étape</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
+155
-48
@@ -6,6 +6,7 @@ import {
|
|||||||
FlatList,
|
FlatList,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
@@ -14,6 +15,8 @@ import { safDirectory } from '../services/safDirectory';
|
|||||||
import { useSyncQueue } from '../hooks/useSyncQueue';
|
import { useSyncQueue } from '../hooks/useSyncQueue';
|
||||||
import { useAutoSync } from '../hooks/useAutoSync';
|
import { useAutoSync } from '../hooks/useAutoSync';
|
||||||
import { useSyncPush } from '../hooks/useSyncPush';
|
import { useSyncPush } from '../hooks/useSyncPush';
|
||||||
|
import { useUploadQueue } from '../hooks/useUploadQueue';
|
||||||
|
import { UploadTask } from '../services/uploadQueue';
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return '';
|
if (bytes === 0) return '';
|
||||||
@@ -22,11 +25,25 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadStatusIcon(task: UploadTask) {
|
||||||
|
switch (task.status) {
|
||||||
|
case 'pending':
|
||||||
|
return <MaterialIcons name="schedule" size={20} color="#FFA000" />;
|
||||||
|
case 'uploading':
|
||||||
|
return <ActivityIndicator size="small" color="#1976D2" />;
|
||||||
|
case 'done':
|
||||||
|
return <MaterialIcons name="check-circle" size={20} color="#4CAF50" />;
|
||||||
|
case 'error':
|
||||||
|
return <MaterialIcons name="error" size={20} color="#E53935" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function SyncDetailScreen() {
|
export function SyncDetailScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
||||||
const { triggerSync } = useAutoSync();
|
const { triggerSync } = useAutoSync();
|
||||||
const { push } = useSyncPush();
|
const { push } = useSyncPush();
|
||||||
|
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
|
||||||
|
|
||||||
const pendingFiles = useMemo(() => {
|
const pendingFiles = useMemo(() => {
|
||||||
return fileStore.getPendingSync();
|
return fileStore.getPendingSync();
|
||||||
@@ -34,74 +51,134 @@ export function SyncDetailScreen() {
|
|||||||
|
|
||||||
const handleSyncAll = useCallback(async () => {
|
const handleSyncAll = useCallback(async () => {
|
||||||
await triggerSync();
|
await triggerSync();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
|
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
|
||||||
const serverId = await getStoredDeviceServerId();
|
const serverId = await getStoredDeviceServerId();
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await push(serverId);
|
await push(serverId);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { }
|
||||||
// push notification is best-effort
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
}, [triggerSync, push, refresh]);
|
}, [triggerSync, push, refresh]);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: FileRecord }) => (
|
const handleTaskPress = useCallback((task: UploadTask) => {
|
||||||
|
if (task.status !== 'error') return;
|
||||||
|
Alert.alert(
|
||||||
|
'Erreur d\'upload',
|
||||||
|
task.error || 'Erreur inconnue',
|
||||||
|
[
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{ text: 'Réessayer', onPress: () => retry(task.id) },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [retry]);
|
||||||
|
|
||||||
|
const hasUploads = uploadTasks.length > 0;
|
||||||
|
const hasPending = pendingFiles.length > 0;
|
||||||
|
const hasContent = hasUploads || hasPending;
|
||||||
|
|
||||||
|
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{(hasPending || uploadErrors.length > 0) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
|
||||||
|
onPress={uploadErrors.length > 0 && hasPending ? retryAll : handleSyncAll}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons
|
||||||
|
name={uploadErrors.length > 0 ? "refresh" : "cloud-upload"}
|
||||||
|
size={20}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={styles.syncBtnText}>
|
||||||
|
{isSyncing ? 'Synchronisation...'
|
||||||
|
: uploadErrors.length > 0 && hasPending
|
||||||
|
? 'Tout réessayer'
|
||||||
|
: hasPending
|
||||||
|
? `Synchroniser (${pendingCount})`
|
||||||
|
: `Réessayer (${uploadErrors.length})`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasContent ? (
|
||||||
|
<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
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={[
|
||||||
|
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
|
||||||
|
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
|
||||||
|
...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []),
|
||||||
|
...pendingFiles.map((f) => ({ type: 'file' as const, data: f })),
|
||||||
|
]}
|
||||||
|
keyExtractor={(item) =>
|
||||||
|
item.type === 'section' ? item.label
|
||||||
|
: item.type === 'upload' ? item.data.id
|
||||||
|
: item.data.id
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
if (item.type === 'section') {
|
||||||
|
return <Text style={styles.headerText}>{item.label}</Text>;
|
||||||
|
}
|
||||||
|
if (item.type === 'upload') {
|
||||||
|
const task = item.data;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.fileRow, task.status === 'error' && styles.fileRowError]}
|
||||||
|
onPress={() => handleTaskPress(task)}
|
||||||
|
activeOpacity={task.status === 'error' ? 0.6 : 1}
|
||||||
|
>
|
||||||
|
{uploadStatusIcon(task)}
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>{task.file.name}</Text>
|
||||||
|
{task.status === 'uploading' && (
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View style={[styles.progressFill, { width: `${task.progress}%` }]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{task.status === 'error' && task.error && (
|
||||||
|
<Text style={styles.errorText} numberOfLines={1}>{task.error}</Text>
|
||||||
|
)}
|
||||||
|
{task.status === 'done' && (
|
||||||
|
<Text style={styles.doneText}>Upload terminé</Text>
|
||||||
|
)}
|
||||||
|
{task.status === 'pending' && (
|
||||||
|
<Text style={styles.pendingText}>En attente</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const file = item.data;
|
||||||
|
return (
|
||||||
<View style={styles.fileRow}>
|
<View style={styles.fileRow}>
|
||||||
<MaterialIcons name="insert-drive-file" size={20} color="#999" />
|
<MaterialIcons name="insert-drive-file" size={20} color="#999" />
|
||||||
<View style={styles.fileInfo}>
|
<View style={styles.fileInfo}>
|
||||||
<Text style={styles.fileName} numberOfLines={1}>{item.name}</Text>
|
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
|
||||||
<Text style={styles.fileMeta}>
|
<Text style={styles.fileMeta}>
|
||||||
{formatSize(item.size)}
|
{formatSize(file.size)}
|
||||||
{item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''}
|
{file.mimeType ? ` · ${file.mimeType.split('/').pop()}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.localBadge}>
|
<View style={styles.localBadge}>
|
||||||
<MaterialIcons name="phone-android" size={14} color="#757575" />
|
<MaterialIcons name="phone-android" size={14} color="#757575" />
|
||||||
</View>
|
</View>
|
||||||
</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}
|
contentContainerStyle={styles.list}
|
||||||
ListHeaderComponent={
|
|
||||||
<Text style={styles.headerText}>
|
|
||||||
{pendingFiles.length} fichier{pendingFiles.length > 1 ? 's' : ''} en attente
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -170,6 +247,36 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
fileRowError: {
|
||||||
|
backgroundColor: '#FFF0F0',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#E0E0E0',
|
||||||
|
borderRadius: 2,
|
||||||
|
marginTop: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#1976D2',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#E53935',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
doneText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4CAF50',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
pendingText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FFA000',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
empty: {
|
empty: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -1,132 +1,18 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { useUpload, UploadFile } from '../hooks/useUpload';
|
import { UploadFile } from '../services/uploadQueue';
|
||||||
import { usePollOcr, SseOcrListener } from '../hooks/usePollOcr';
|
import { useUploadQueue } from '../hooks/useUploadQueue';
|
||||||
import { UploadProgress } from './UploadProgress';
|
|
||||||
import { UploadError } from '../types';
|
|
||||||
import { apiClient } from '../api/client';
|
|
||||||
import { ENDPOINTS } from '../constants/api';
|
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUploadComplete?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUploadErrorMessage(err: UploadError): string {
|
export function UploadModal({ visible, onClose }: UploadModalProps) {
|
||||||
switch (err.status) {
|
const { enqueue } = useUploadQueue();
|
||||||
case 400:
|
|
||||||
return `${err.fileName} : format invalide (${err.message})`;
|
|
||||||
case 404:
|
|
||||||
return `${err.fileName} : endpoint introuvable`;
|
|
||||||
case 413:
|
|
||||||
return `${err.fileName} : fichier trop volumineux`;
|
|
||||||
case 500:
|
|
||||||
return `${err.fileName} : erreur serveur (${err.message})`;
|
|
||||||
case 0:
|
|
||||||
return `${err.fileName} : impossible de contacter le serveur`;
|
|
||||||
default:
|
|
||||||
return `${err.fileName} : erreur ${err.status} (${err.message})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalProps) {
|
|
||||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'success' | 'error'>('idle');
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const [uploadedCount, setUploadedCount] = useState(0);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
const upload = useUpload();
|
|
||||||
const { pollOcr } = usePollOcr();
|
|
||||||
|
|
||||||
const canClose = status === 'idle' || status === 'success' || status === 'error';
|
|
||||||
|
|
||||||
const checkDupBeforeUpload = useCallback(async (files: UploadFile[]): Promise<UploadFile[]> => {
|
|
||||||
const toUpload: UploadFile[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const result = await apiClient.post<{ data: { duplicates: Array<{ id: string; name: string }>; count: number } }>(
|
|
||||||
ENDPOINTS.DEDUP_CHECK,
|
|
||||||
{ name: file.name, size: 0 },
|
|
||||||
);
|
|
||||||
const duplicates = result.data?.duplicates ?? [];
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
const names = duplicates.map((d: { name: string }) => d.name).join(', ');
|
|
||||||
let proceed = false;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
Alert.alert(
|
|
||||||
'Fichier existant',
|
|
||||||
`"${file.name}" existe déjà sur le serveur (${names}).\nUploader quand même ?`,
|
|
||||||
[
|
|
||||||
{ text: 'Ignorer', style: 'cancel', onPress: () => resolve() },
|
|
||||||
{ text: 'Uploader', onPress: () => { proceed = true; resolve(); } },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (proceed) toUpload.push(file);
|
|
||||||
} else {
|
|
||||||
toUpload.push(file);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toUpload.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toUpload;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const doUpload = async (files: UploadFile[]) => {
|
|
||||||
setStatus('uploading');
|
|
||||||
setUploadedCount(0);
|
|
||||||
setTotalCount(files.length);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deduped = await checkDupBeforeUpload(files);
|
|
||||||
if (deduped.length === 0) {
|
|
||||||
setStatus('success');
|
|
||||||
setUploadedCount(files.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTotalCount(deduped.length);
|
|
||||||
const response = await upload.mutateAsync(deduped);
|
|
||||||
setUploadedCount(response.uploaded.length);
|
|
||||||
|
|
||||||
if (response.uploaded.length > 0) {
|
|
||||||
setStatus('processing');
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
response.uploaded.map((f) => pollOcr(f.id)),
|
|
||||||
);
|
|
||||||
const completed = results.filter(
|
|
||||||
(r) => r.status === 'fulfilled' && r.value.status === 'completed',
|
|
||||||
).length;
|
|
||||||
if (completed > 0) {
|
|
||||||
setStatus('success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.errors.length > 0) {
|
|
||||||
setStatus('error');
|
|
||||||
const messages = response.errors.map((e) => getUploadErrorMessage(e));
|
|
||||||
setError(
|
|
||||||
`${response.uploaded.length}/${totalCount} uploadés.\n${messages.join('\n')}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setStatus('success');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus('error');
|
|
||||||
if (err instanceof UploadError) {
|
|
||||||
setError(getUploadErrorMessage(err));
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
setError(err.message);
|
|
||||||
} else {
|
|
||||||
setError("Erreur inconnue lors de l'upload");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickImages = async () => {
|
const pickImages = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -150,8 +36,9 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
|
|||||||
name: asset.fileName || 'photo.jpg',
|
name: asset.fileName || 'photo.jpg',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await doUpload(files);
|
enqueue(files);
|
||||||
} catch (err) {
|
onClose();
|
||||||
|
} catch {
|
||||||
Alert.alert('Erreur', "Impossible d'accéder à la galerie");
|
Alert.alert('Erreur', "Impossible d'accéder à la galerie");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,73 +58,38 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
|
|||||||
name: asset.name,
|
name: asset.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await doUpload(files);
|
enqueue(files);
|
||||||
} catch (err) {
|
onClose();
|
||||||
|
} catch {
|
||||||
Alert.alert('Erreur', "Impossible de sélectionner des documents");
|
Alert.alert('Erreur', "Impossible de sélectionner des documents");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (!canClose) return;
|
|
||||||
setStatus('idle');
|
|
||||||
setError(undefined);
|
|
||||||
setUploadedCount(0);
|
|
||||||
setTotalCount(0);
|
|
||||||
if (status === 'success') {
|
|
||||||
onUploadComplete?.();
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}, [canClose, status, onClose, onUploadComplete]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === 'success') {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setStatus('idle');
|
|
||||||
setError(undefined);
|
|
||||||
setUploadedCount(0);
|
|
||||||
setTotalCount(0);
|
|
||||||
onUploadComplete?.();
|
|
||||||
onClose();
|
|
||||||
}, 2000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [status, onClose, onUploadComplete]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
onRequestClose={handleClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.overlay}
|
style={styles.overlay}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={canClose ? handleClose : undefined}
|
onPress={onClose}
|
||||||
>
|
>
|
||||||
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||||
<View style={styles.handle} />
|
<View style={styles.handle} />
|
||||||
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>Ajouter des fichiers</Text>
|
<Text style={styles.title}>Ajouter des fichiers</Text>
|
||||||
{canClose && (
|
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||||
<TouchableOpacity onPress={handleClose} style={styles.closeBtn}>
|
|
||||||
<MaterialIcons name="close" size={22} color="#999" />
|
<MaterialIcons name="close" size={22} color="#999" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<UploadProgress
|
|
||||||
status={status}
|
|
||||||
error={error}
|
|
||||||
uploadedCount={uploadedCount}
|
|
||||||
totalCount={totalCount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.photoButton}
|
style={styles.photoButton}
|
||||||
onPress={pickImages}
|
onPress={pickImages}
|
||||||
disabled={status === 'uploading' || status === 'processing'}
|
|
||||||
>
|
>
|
||||||
<MaterialIcons name="photo-library" size={20} color="#fff" />
|
<MaterialIcons name="photo-library" size={20} color="#fff" />
|
||||||
<Text style={styles.buttonText}>Sélectionner des photos</Text>
|
<Text style={styles.buttonText}>Sélectionner des photos</Text>
|
||||||
@@ -246,7 +98,6 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.docButton}
|
style={styles.docButton}
|
||||||
onPress={pickDocuments}
|
onPress={pickDocuments}
|
||||||
disabled={status === 'uploading' || status === 'processing'}
|
|
||||||
>
|
>
|
||||||
<MaterialIcons name="description" size={20} color="#fff" />
|
<MaterialIcons name="description" size={20} color="#fff" />
|
||||||
<Text style={styles.buttonText}>Sélectionner des documents</Text>
|
<Text style={styles.buttonText}>Sélectionner des documents</Text>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const CURRENT_ONBOARDING_VERSION = 1;
|
export const CURRENT_ONBOARDING_VERSION = 2;
|
||||||
|
|
||||||
export type OnboardingAction = {
|
export type OnboardingAction = {
|
||||||
type: 'pick_directory';
|
type: 'pick_directory' | 'recursive_scan';
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,25 +18,18 @@ export type OnboardingStep = {
|
|||||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
{
|
{
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
version: 1,
|
version: 2,
|
||||||
title: 'Bienvenue sur Dot.',
|
title: 'Bienvenue sur Dot.',
|
||||||
description: 'Votre espace document personnel, toujours accessible.',
|
description: 'Votre espace document personnel, toujours accessible.',
|
||||||
icon: 'waving-hand',
|
icon: 'waving-hand',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'folder_explanation',
|
id: 'pick_root_folder',
|
||||||
version: 1,
|
version: 2,
|
||||||
title: 'Pourquoi un dossier dédié ?',
|
title: 'Choisissez votre dossier principal',
|
||||||
description: 'Depuis Android 13, les applications ne peuvent plus accéder librement au dossier Téléchargements (par sécurité).\n\nDot. utilise un mécanisme spécial (SAF) pour lire vos fichiers. En créant un dossier dédié, vous autorisez l\'application à y accéder en toute sécurité.\n\n→ Déplacez vos documents dans ce dossier et ils apparaîtront automatiquement dans Dot.',
|
description: 'Sélectionnez la racine de votre stockage dans le sélecteur.\n\nDot. va scanner automatiquement tous les sous-dossiers (photos, téléchargements, documents…).',
|
||||||
icon: 'folder-off',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'create_folder',
|
|
||||||
version: 1,
|
|
||||||
title: 'Créez votre dossier',
|
|
||||||
description: 'Sélectionnez ou créez un dossier dans le sélecteur ci-dessous.',
|
|
||||||
icon: 'create-new-folder',
|
icon: 'create-new-folder',
|
||||||
action: { type: 'pick_directory', label: 'Sélectionner un dossier' },
|
action: { type: 'recursive_scan', label: 'Choisir le dossier principal' },
|
||||||
condition: 'has_no_folders',
|
condition: 'has_no_folders',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function scanMediaAlbum(folder: StoredFolder): Promise<DeviceFile[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanSubdirectories(
|
export async function scanSubdirectories(
|
||||||
baseUri: string,
|
baseUri: string,
|
||||||
depth: number = 0,
|
depth: number = 0,
|
||||||
maxDepth: number = 3,
|
maxDepth: number = 3,
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { ENDPOINTS } from '../constants/api';
|
import { ENDPOINTS } from '../constants/api';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
import { useOcrDone } from '../contexts/SseContext';
|
import { useOcrDone } from '../contexts/SseContext';
|
||||||
|
|
||||||
export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' };
|
|
||||||
|
|
||||||
type OcrState = Record<string, { resolve: (res: OcrPollResult) => void; timeout: ReturnType<typeof setTimeout> }>;
|
|
||||||
|
|
||||||
let globalOcrState: OcrState = {};
|
|
||||||
let globalSetState: React.Dispatch<React.SetStateAction<number>> | null = null;
|
|
||||||
|
|
||||||
function handleOcrDone(resourceId: string) {
|
function handleOcrDone(resourceId: string) {
|
||||||
const entry = globalOcrState[resourceId];
|
|
||||||
if (!entry) return;
|
|
||||||
clearTimeout(entry.timeout);
|
|
||||||
|
|
||||||
apiClient
|
apiClient
|
||||||
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
|
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
|
||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
@@ -23,15 +12,8 @@ function handleOcrDone(resourceId: string) {
|
|||||||
if (ocrText) {
|
if (ocrText) {
|
||||||
fileStore.updatePartial(resourceId, { ocrText });
|
fileStore.updatePartial(resourceId, { ocrText });
|
||||||
}
|
}
|
||||||
entry.resolve({ resourceId, status: 'completed' });
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {});
|
||||||
entry.resolve({ resourceId, status: 'failed' });
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
delete globalOcrState[resourceId];
|
|
||||||
globalSetState?.(Date.now());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SseOcrListener() {
|
export function SseOcrListener() {
|
||||||
@@ -44,33 +26,3 @@ export function SseOcrListener() {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePollOcr() {
|
|
||||||
const [, forceUpdate] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
globalSetState = forceUpdate;
|
|
||||||
return () => {
|
|
||||||
globalSetState = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pollOcr = useCallback(async (resourceId: string): Promise<OcrPollResult> => {
|
|
||||||
const existing = globalOcrState[resourceId];
|
|
||||||
if (existing) {
|
|
||||||
return new Promise((resolve) => existing.resolve = resolve);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
delete globalOcrState[resourceId];
|
|
||||||
resolve({ resourceId, status: 'timeout' });
|
|
||||||
}, 120_000);
|
|
||||||
|
|
||||||
globalOcrState[resourceId] = { resolve, timeout };
|
|
||||||
globalSetState?.(Date.now());
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { pollOcr };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { uploadQueue, UploadFile } from '../services/uploadQueue';
|
||||||
|
|
||||||
|
export function useUploadQueue() {
|
||||||
|
const tasks = useSyncExternalStore(
|
||||||
|
uploadQueue.subscribe.bind(uploadQueue),
|
||||||
|
uploadQueue.getTasks.bind(uploadQueue),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
enqueue: (files: UploadFile[]) => uploadQueue.enqueue(files),
|
||||||
|
cancel: (id: string) => uploadQueue.cancel(id),
|
||||||
|
retry: (id: string) => uploadQueue.retry(id),
|
||||||
|
retryAll: () => uploadQueue.retryAll(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { File, UploadType } from 'expo-file-system';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||||
|
import { ApiError, UploadError } from '../types';
|
||||||
|
|
||||||
|
export type UploadFile = { uri: string; type: string; name: string };
|
||||||
|
export type UploadResult = { name: string; id: string };
|
||||||
|
|
||||||
|
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
||||||
|
|
||||||
|
export type UploadTask = {
|
||||||
|
id: string;
|
||||||
|
file: UploadFile;
|
||||||
|
status: UploadTaskStatus;
|
||||||
|
progress: number;
|
||||||
|
result?: UploadResult;
|
||||||
|
error?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
function genId() {
|
||||||
|
nextId++;
|
||||||
|
return `upload_${Date.now()}_${nextId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadQueue {
|
||||||
|
private tasks: UploadTask[] = [];
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
private concurrency = 3;
|
||||||
|
private active = 0;
|
||||||
|
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
getTasks(): UploadTask[] {
|
||||||
|
return this.tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify() {
|
||||||
|
this.listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(files: UploadFile[]) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const file of files) {
|
||||||
|
this.tasks.push({
|
||||||
|
id: genId(),
|
||||||
|
file,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
this.processNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(id: string) {
|
||||||
|
const task = this.tasks.find((t) => t.id === id);
|
||||||
|
if (!task || task.status === 'done') return;
|
||||||
|
task.status = 'error';
|
||||||
|
task.error = 'Annulé';
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(id: string) {
|
||||||
|
const task = this.tasks.find((t) => t.id === id);
|
||||||
|
if (!task || task.status !== 'error') return;
|
||||||
|
task.status = 'pending';
|
||||||
|
task.progress = 0;
|
||||||
|
task.error = undefined;
|
||||||
|
task.result = undefined;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
this.notify();
|
||||||
|
this.processNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAll() {
|
||||||
|
for (const task of this.tasks) {
|
||||||
|
if (task.status === 'error') {
|
||||||
|
task.status = 'pending';
|
||||||
|
task.progress = 0;
|
||||||
|
task.error = undefined;
|
||||||
|
task.result = undefined;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.notify();
|
||||||
|
this.processNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private processNext() {
|
||||||
|
while (this.active < this.concurrency) {
|
||||||
|
const next = this.tasks.find((t) => t.status === 'pending');
|
||||||
|
if (!next) break;
|
||||||
|
this.active++;
|
||||||
|
next.status = 'uploading';
|
||||||
|
next.updatedAt = Date.now();
|
||||||
|
this.notify();
|
||||||
|
this.runTask(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runTask(task: UploadTask) {
|
||||||
|
try {
|
||||||
|
const fsFile = new File(task.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: task.file.type,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
let serverMessage = 'Erreur serveur';
|
||||||
|
try {
|
||||||
|
const body: ApiError = JSON.parse(result.body);
|
||||||
|
serverMessage = body.error?.message || serverMessage;
|
||||||
|
} catch {
|
||||||
|
serverMessage = result.body || serverMessage;
|
||||||
|
}
|
||||||
|
throw new UploadError(task.file.name, result.status, serverMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.parse(result.body);
|
||||||
|
const items = body.data ?? body;
|
||||||
|
const item = Array.isArray(items) ? items[0] : items;
|
||||||
|
|
||||||
|
task.status = 'done';
|
||||||
|
task.progress = 100;
|
||||||
|
task.result = item as UploadResult;
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
this.notify();
|
||||||
|
this.scheduleCleanup();
|
||||||
|
} catch (err) {
|
||||||
|
task.status = 'error';
|
||||||
|
task.error =
|
||||||
|
err instanceof UploadError
|
||||||
|
? `${err.fileName} : ${err.message}`
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Erreur inconnue';
|
||||||
|
task.updatedAt = Date.now();
|
||||||
|
this.notify();
|
||||||
|
} finally {
|
||||||
|
this.active--;
|
||||||
|
this.notify();
|
||||||
|
this.processNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleCleanup() {
|
||||||
|
if (this.cleanupTimer) return;
|
||||||
|
this.cleanupTimer = setTimeout(() => {
|
||||||
|
this.cleanupTimer = null;
|
||||||
|
const now = Date.now();
|
||||||
|
const before = this.tasks.length;
|
||||||
|
this.tasks = this.tasks.filter(
|
||||||
|
(t) => t.status !== 'done' || now - t.updatedAt < 5000,
|
||||||
|
);
|
||||||
|
if (this.tasks.length !== before) {
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
if (this.tasks.some((t) => t.status === 'done')) {
|
||||||
|
this.scheduleCleanup();
|
||||||
|
}
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadQueue = new UploadQueue();
|
||||||
Reference in New Issue
Block a user