diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 12e7a04..375d3f6 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -118,10 +118,6 @@ export function HomeScreen() { const { pendingCount, isSyncing } = useSyncQueue(); useAutoSync(); - const handleUploadComplete = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['resources'] }); - }, [queryClient]); - const itemSize = (SCREEN_WIDTH - PADDING_H * 2 - (numColumns - 1) * ITEM_GAP) / numColumns; const loadMore = useCallback(() => { @@ -659,7 +655,6 @@ export function HomeScreen() { setUploadModalVisible(false)} - onUploadComplete={handleUploadComplete} /> = { - 'waving-hand': 'waving-hand', - 'folder-off': 'folder-off', - 'create-new-folder': 'create-new-folder', -}; +type ScanState = 'idle' | 'scanning' | 'done'; + +function stepIcon(step: OnboardingStep): keyof typeof MaterialIcons.glyphMap { + 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 ( + <> + + + + Scan en cours... + + Dot. explore les sous-dossiers de votre stockage. Cela peut prendre quelques secondes. + + + ); +} + +function ScanDoneView({ folderCount }: { folderCount: number }) { + return ( + <> + + + + Scan terminé ! + + {folderCount > 1 + ? `${folderCount} dossiers découverts et ajoutés à votre espace Dot.` + : '1 dossier ajouté à votre espace Dot.'} + + + ); +} export function OnboardingScreen() { const navigation = useNavigation(); const [currentIndex, setCurrentIndex] = useState(0); + const [scanState, setScanState] = useState('idle'); + const [folderCount, setFolderCount] = useState(0); const pendingSteps = onboardingStorage.getPendingSteps(); const step = pendingSteps[currentIndex]; @@ -31,19 +71,33 @@ export function OnboardingScreen() { navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] }); }, [navigation]); - const handlePickDirectory = useCallback(async () => { - const { safDirectory } = await import('../services/safDirectory'); - const FileSystem = await import('expo-file-system/legacy'); - + const handleRecursiveScan = useCallback(async () => { try { const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(); if (!result.granted) return; + const dirUri = result.directoryUri; 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); + + 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) { - 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) { setCurrentIndex(currentIndex + 1); + setScanState('idle'); + setFolderCount(0); } else { complete(); } @@ -67,6 +123,8 @@ export function OnboardingScreen() { return null; } + const isScanStep = step.action?.type === 'recursive_scan'; + return ( @@ -76,22 +134,25 @@ export function OnboardingScreen() { - - - + {scanState === 'scanning' && isScanStep ? ( + + ) : scanState === 'done' && isScanStep ? ( + + ) : ( + <> + + + + {step.title} + {step.description} - {step.title} - {step.description} - - {step.action?.type === 'pick_directory' && ( - - - {step.action.label} - + {isScanStep && scanState === 'idle' && ( + + + {step.action?.label ?? 'Choisir un dossier'} + + )} + )} @@ -105,12 +166,21 @@ export function OnboardingScreen() { ))} - - - {currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'} - - - + {(!isScanStep || scanState === 'done') && ( + + + {currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'} + + + + )} + + {isScanStep && scanState === 'idle' && ( + + Passer cette étape + + + )} ); diff --git a/mobile/app/sync-detail.tsx b/mobile/app/sync-detail.tsx index 82c3ba1..32be10e 100644 --- a/mobile/app/sync-detail.tsx +++ b/mobile/app/sync-detail.tsx @@ -6,6 +6,7 @@ import { FlatList, TouchableOpacity, ActivityIndicator, + Alert, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; @@ -14,6 +15,8 @@ import { safDirectory } from '../services/safDirectory'; import { useSyncQueue } from '../hooks/useSyncQueue'; import { useAutoSync } from '../hooks/useAutoSync'; import { useSyncPush } from '../hooks/useSyncPush'; +import { useUploadQueue } from '../hooks/useUploadQueue'; +import { UploadTask } from '../services/uploadQueue'; function formatSize(bytes: number): string { if (bytes === 0) return ''; @@ -22,11 +25,25 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`; } +function uploadStatusIcon(task: UploadTask) { + switch (task.status) { + case 'pending': + return ; + case 'uploading': + return ; + case 'done': + return ; + case 'error': + return ; + } +} + export function SyncDetailScreen() { const insets = useSafeAreaInsets(); const { pendingCount, isSyncing, refresh } = useSyncQueue(); const { triggerSync } = useAutoSync(); const { push } = useSyncPush(); + const { tasks: uploadTasks, retry, retryAll } = useUploadQueue(); const pendingFiles = useMemo(() => { return fileStore.getPendingSync(); @@ -34,74 +51,134 @@ export function SyncDetailScreen() { const handleSyncAll = useCallback(async () => { await triggerSync(); - try { const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration'); const serverId = await getStoredDeviceServerId(); if (serverId) { await push(serverId); } - } catch { - // push notification is best-effort - } - + } catch { } refresh(); }, [triggerSync, push, refresh]); - const renderItem = useCallback(({ item }: { item: FileRecord }) => ( - - - - {item.name} - - {formatSize(item.size)} - {item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''} - - - - - - - ), []); + 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 ( - {pendingCount > 0 && ( + {(hasPending || uploadErrors.length > 0) && ( 0 && hasPending ? retryAll : handleSyncAll} disabled={isSyncing} > {isSyncing ? ( ) : ( - + 0 ? "refresh" : "cloud-upload"} + size={20} + color="#fff" + /> )} - {isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`} + {isSyncing ? 'Synchronisation...' + : uploadErrors.length > 0 && hasPending + ? 'Tout réessayer' + : hasPending + ? `Synchroniser (${pendingCount})` + : `Réessayer (${uploadErrors.length})`} )} - {pendingFiles.length === 0 ? ( + {!hasContent ? ( Tout est synchronisé - Aucun fichier en attente d'upload + Aucun fichier en attente ) : ( item.id} - renderItem={renderItem} - contentContainerStyle={styles.list} - ListHeaderComponent={ - - {pendingFiles.length} fichier{pendingFiles.length > 1 ? 's' : ''} en attente - + 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 {item.label}; + } + if (item.type === 'upload') { + const task = item.data; + return ( + handleTaskPress(task)} + activeOpacity={task.status === 'error' ? 0.6 : 1} + > + {uploadStatusIcon(task)} + + {task.file.name} + {task.status === 'uploading' && ( + + + + )} + {task.status === 'error' && task.error && ( + {task.error} + )} + {task.status === 'done' && ( + Upload terminé + )} + {task.status === 'pending' && ( + En attente + )} + + + ); + } + const file = item.data; + return ( + + + + {file.name} + + {formatSize(file.size)} + {file.mimeType ? ` · ${file.mimeType.split('/').pop()}` : ''} + + + + + + + ); + }} + contentContainerStyle={styles.list} /> )} @@ -170,6 +247,36 @@ const styles = StyleSheet.create({ justifyContent: '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: { flex: 1, justifyContent: 'center', diff --git a/mobile/components/UploadModal.tsx b/mobile/components/UploadModal.tsx index dfd8be0..cc3ca18 100644 --- a/mobile/components/UploadModal.tsx +++ b/mobile/components/UploadModal.tsx @@ -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 { MaterialIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; -import { useUpload, UploadFile } from '../hooks/useUpload'; -import { usePollOcr, SseOcrListener } from '../hooks/usePollOcr'; -import { UploadProgress } from './UploadProgress'; -import { UploadError } from '../types'; -import { apiClient } from '../api/client'; -import { ENDPOINTS } from '../constants/api'; +import { UploadFile } from '../services/uploadQueue'; +import { useUploadQueue } from '../hooks/useUploadQueue'; interface UploadModalProps { visible: boolean; onClose: () => void; - onUploadComplete?: () => void; } -function getUploadErrorMessage(err: UploadError): string { - switch (err.status) { - 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(); - 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 => { - 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((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"); - } - } - }; +export function UploadModal({ visible, onClose }: UploadModalProps) { + const { enqueue } = useUploadQueue(); const pickImages = async () => { try { @@ -150,8 +36,9 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP name: asset.fileName || 'photo.jpg', })); - await doUpload(files); - } catch (err) { + enqueue(files); + onClose(); + } catch { Alert.alert('Erreur', "Impossible d'accéder à la galerie"); } }; @@ -171,73 +58,38 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP name: asset.name, })); - await doUpload(files); - } catch (err) { + enqueue(files); + onClose(); + } catch { 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 ( {}}> Ajouter des fichiers - {canClose && ( - - - - )} + + + - - Sélectionner des photos @@ -246,7 +98,6 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP Sélectionner des documents diff --git a/mobile/config/onboarding.ts b/mobile/config/onboarding.ts index bddda85..fac753b 100644 --- a/mobile/config/onboarding.ts +++ b/mobile/config/onboarding.ts @@ -1,7 +1,7 @@ -export const CURRENT_ONBOARDING_VERSION = 1; +export const CURRENT_ONBOARDING_VERSION = 2; export type OnboardingAction = { - type: 'pick_directory'; + type: 'pick_directory' | 'recursive_scan'; label: string; }; @@ -18,25 +18,18 @@ export type OnboardingStep = { export const ONBOARDING_STEPS: OnboardingStep[] = [ { id: 'welcome', - version: 1, + version: 2, title: 'Bienvenue sur Dot.', description: 'Votre espace document personnel, toujours accessible.', icon: 'waving-hand', }, { - id: 'folder_explanation', - version: 1, - title: 'Pourquoi un dossier dédié ?', - 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.', - 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.', + id: 'pick_root_folder', + version: 2, + title: 'Choisissez votre dossier principal', + 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: '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', }, ]; diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts index 1cb82f1..9929d1f 100644 --- a/mobile/hooks/useDeviceFiles.ts +++ b/mobile/hooks/useDeviceFiles.ts @@ -114,7 +114,7 @@ async function scanMediaAlbum(folder: StoredFolder): Promise { } } -async function scanSubdirectories( +export async function scanSubdirectories( baseUri: string, depth: number = 0, maxDepth: number = 3, diff --git a/mobile/hooks/usePollOcr.ts b/mobile/hooks/usePollOcr.ts index f171a31..320147d 100644 --- a/mobile/hooks/usePollOcr.ts +++ b/mobile/hooks/usePollOcr.ts @@ -1,21 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect } from 'react'; import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; import { fileStore } from '../services/fileStore'; import { useOcrDone } from '../contexts/SseContext'; -export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' }; - -type OcrState = Record void; timeout: ReturnType }>; - -let globalOcrState: OcrState = {}; -let globalSetState: React.Dispatch> | null = null; - function handleOcrDone(resourceId: string) { - const entry = globalOcrState[resourceId]; - if (!entry) return; - clearTimeout(entry.timeout); - apiClient .get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`) .then((detail) => { @@ -23,15 +12,8 @@ function handleOcrDone(resourceId: string) { if (ocrText) { fileStore.updatePartial(resourceId, { ocrText }); } - entry.resolve({ resourceId, status: 'completed' }); }) - .catch(() => { - entry.resolve({ resourceId, status: 'failed' }); - }) - .finally(() => { - delete globalOcrState[resourceId]; - globalSetState?.(Date.now()); - }); + .catch(() => {}); } export function SseOcrListener() { @@ -44,33 +26,3 @@ export function SseOcrListener() { return null; } - -export function usePollOcr() { - const [, forceUpdate] = useState(0); - - useEffect(() => { - globalSetState = forceUpdate; - return () => { - globalSetState = null; - }; - }, []); - - const pollOcr = useCallback(async (resourceId: string): Promise => { - 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 }; -} diff --git a/mobile/hooks/useUploadQueue.ts b/mobile/hooks/useUploadQueue.ts new file mode 100644 index 0000000..92ee1c6 --- /dev/null +++ b/mobile/hooks/useUploadQueue.ts @@ -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(), + }; +} diff --git a/mobile/services/uploadQueue.ts b/mobile/services/uploadQueue.ts new file mode 100644 index 0000000..9f6c572 --- /dev/null +++ b/mobile/services/uploadQueue.ts @@ -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(); + private concurrency = 3; + private active = 0; + private cleanupTimer: ReturnType | 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 = {}; + 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();