background fetch
This commit is contained in:
@@ -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() {
|
||||
<UploadModal
|
||||
visible={uploadModalVisible}
|
||||
onClose={() => setUploadModalVisible(false)}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
|
||||
+90
-20
@@ -5,23 +5,63 @@ import {
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
|
||||
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 ICON_MAP: Record<string, keyof typeof MaterialIcons.glyphMap> = {
|
||||
'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 (
|
||||
<>
|
||||
<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() {
|
||||
const navigation = useNavigation();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [scanState, setScanState] = useState<ScanState>('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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.skipContainer}>
|
||||
@@ -76,23 +134,26 @@ export function OnboardingScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{scanState === 'scanning' && isScanStep ? (
|
||||
<ScanProgressView />
|
||||
) : scanState === 'done' && isScanStep ? (
|
||||
<ScanDoneView folderCount={folderCount} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialIcons
|
||||
name={ICON_MAP[step.icon] ?? 'info'}
|
||||
size={64}
|
||||
color="#1976D2"
|
||||
/>
|
||||
<MaterialIcons name={stepIcon(step)} size={64} color="#1976D2" />
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>{step.title}</Text>
|
||||
<Text style={styles.description}>{step.description}</Text>
|
||||
|
||||
{step.action?.type === 'pick_directory' && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handlePickDirectory}>
|
||||
{isScanStep && scanState === 'idle' && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleRecursiveScan}>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
@@ -105,12 +166,21 @@ export function OnboardingScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{(!isScanStep || scanState === 'done') && (
|
||||
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
|
||||
<Text style={styles.nextBtnText}>
|
||||
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
|
||||
</Text>
|
||||
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
|
||||
</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>
|
||||
);
|
||||
|
||||
+155
-48
@@ -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 <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() {
|
||||
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 }) => (
|
||||
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}>
|
||||
<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.fileName} numberOfLines={1}>{file.name}</Text>
|
||||
<Text style={styles.fileMeta}>
|
||||
{formatSize(item.size)}
|
||||
{item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''}
|
||||
{formatSize(file.size)}
|
||||
{file.mimeType ? ` · ${file.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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
activeOpacity={1}
|
||||
onPress={canClose ? handleClose : undefined}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
|
||||
<View style={styles.handle} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Ajouter des fichiers</Text>
|
||||
{canClose && (
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeBtn}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||
<MaterialIcons name="close" size={22} color="#999" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<UploadProgress
|
||||
status={status}
|
||||
error={error}
|
||||
uploadedCount={uploadedCount}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.photoButton}
|
||||
onPress={pickImages}
|
||||
disabled={status === 'uploading' || status === 'processing'}
|
||||
>
|
||||
<MaterialIcons name="photo-library" size={20} color="#fff" />
|
||||
<Text style={styles.buttonText}>Sélectionner des photos</Text>
|
||||
@@ -246,7 +98,6 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
|
||||
<TouchableOpacity
|
||||
style={styles.docButton}
|
||||
onPress={pickDocuments}
|
||||
disabled={status === 'uploading' || status === 'processing'}
|
||||
>
|
||||
<MaterialIcons name="description" size={20} color="#fff" />
|
||||
<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 = {
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -114,7 +114,7 @@ async function scanMediaAlbum(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSubdirectories(
|
||||
export async function scanSubdirectories(
|
||||
baseUri: string,
|
||||
depth: number = 0,
|
||||
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 { 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<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) {
|
||||
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<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