diff --git a/mobile/App.tsx b/mobile/App.tsx index 5d4ed82..1396fe1 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -11,7 +11,6 @@ import { DeviceProvider, useDevice } from './contexts/DeviceContext'; import { LoginScreen } from './app/login'; import { RegisterScreen } from './app/register'; import { HomeScreen } from './app/index'; -import { UploadScreen } from './app/upload'; import { ScanScreen } from './app/scan'; import { SearchScreen } from './app/search'; import { BatchReviewScreen } from './app/batch-review'; @@ -77,7 +76,6 @@ function AppNavigator() { headerTitleStyle: { fontSize: 18 }, }} /> - diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index f2ebe1c..44a3323 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -4,15 +4,17 @@ import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialIcons } from '@expo/vector-icons'; -import { useDeleteFile, useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; import { UnifiedFileItem, isFolder } from '../types'; -import { SearchBar, SearchFilters } from '../components/SearchBar'; +import { SearchBar, SearchFilters, MediaFilter } from '../components/SearchBar'; import { FileThumbnail } from '../components/FileThumbnail'; import { SettingsModal } from '../components/SettingsModal'; +import { UploadModal } from '../components/UploadModal'; import { SyncStatusIcon } from '../components/SyncStatusIcon'; import { useSyncQueue } from '../hooks/useSyncQueue'; import { useAutoSync } from '../hooks/useAutoSync'; -import { safDirectory, StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory'; +import { safDirectory, SyncMode, SyncGlobalMode } from '../services/safDirectory'; import { fileStore } from '../services/fileStore'; import { downloadRegistry } from '../services/downloadRegistry'; import { useQueryClient } from '@tanstack/react-query'; @@ -22,9 +24,9 @@ import { useLocalFiles } from '../hooks/useLocalFiles'; import { deleteAsync } from 'expo-file-system/legacy'; import { useDebounce } from '../hooks/useDebounce'; -const NUM_COLUMNS = 3; const SCREEN_WIDTH = Dimensions.get('window').width; -const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS; +const PADDING_H = 15; +const ITEM_GAP = 6; type RootStackParamList = { Home: undefined; @@ -38,8 +40,6 @@ type RootStackParamList = { type NavigationProp = NativeStackNavigationProp; -type GroupedFiles = Record; - function parseBackendDate(dateStr: string): Date | null { if (!dateStr) return null; const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/); @@ -47,53 +47,10 @@ function parseBackendDate(dateStr: string): Date | null { return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6])); } -function groupByDate(files: UnifiedFileItem[]): GroupedFiles { - const groups: GroupedFiles = {}; - for (const file of files) { - const d = parseBackendDate(file.createdAt); - if (!d) continue; - const key = d.toLocaleDateString('fr-FR', { - weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', - }); - if (!groups[key]) groups[key] = []; - groups[key].push(file); - } - return groups; -} - -function groupByTag(files: UnifiedFileItem[]): GroupedFiles { - const groups: GroupedFiles = {}; - for (const file of files) { - const tags = file.tags ?? []; - if (tags.length === 0) { - if (!groups['Sans tag']) groups['Sans tag'] = []; - groups['Sans tag'].push(file); - } else { - for (const tag of tags) { - const name = typeof tag === 'string' ? tag : tag.tag_name; - if (!name) continue; - if (!groups[name]) groups[name] = []; - groups[name].push(file); - } - } - } - return groups; -} - -function formatDateLabel(key: string): string { - const d = new Date(); - const today = d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); - d.setDate(d.getDate() - 1); - const yesterday = d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); - if (key === today) return "Aujourd'hui"; - if (key === yesterday) return 'Hier'; - return key.charAt(0).toUpperCase() + key.slice(1); -} - -const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: (f: UnifiedFileItem) => void; onLongPress?: (f: UnifiedFileItem) => void; selected?: boolean }) { +const FileGridItem = React.memo(function FileGridItem({ file, size, onPress, onLongPress, selected }: { file: UnifiedFileItem; size: number; onPress?: (f: UnifiedFileItem) => void; onLongPress?: (f: UnifiedFileItem) => void; selected?: boolean }) { return ( onPress?.(file)} onLongPress={() => onLongPress?.(file)} delayLongPress={400} @@ -104,7 +61,7 @@ const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPre thumbnailUrl={file.thumbnailUrl} mimeType={file.mimeType} fileName={file.name} - size={ITEM_SIZE} + size={size} syncStatus={file.syncStatus} /> {selected && ( @@ -114,48 +71,18 @@ const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPre )} - {file.name} ); }); -const FileGroup = React.memo(function FileGroup({ groupFiles, selectedIds, onItemPress, onItemLongPress }: { - groupFiles: UnifiedFileItem[]; - selectedIds: Set; - onItemPress: (file: UnifiedFileItem) => void; - onItemLongPress: (file: UnifiedFileItem) => void; -}) { - return ( - - {groupFiles.map((file) => ( - - ))} - - ); -}); - function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean { if (!query) return true; const q = query.toLowerCase(); if (filters.name && file.name.toLowerCase().includes(q)) return true; if (filters.ocrText && file.ocrText?.toLowerCase().includes(q)) return true; - if (filters.tags && file.tags?.some((t) => { - const tagName = typeof t === 'string' ? t : t.tag_name; - return tagName?.toLowerCase().includes(q); - })) return true; - if (!filters.name && !filters.ocrText && !filters.tags) { + if (!filters.name && !filters.ocrText) { if (file.name.toLowerCase().includes(q)) return true; if (file.ocrText?.toLowerCase().includes(q)) return true; - if (file.tags?.some((t) => { - const tagName = typeof t === 'string' ? t : t.tag_name; - return tagName?.toLowerCase().includes(q); - })) return true; } return false; } @@ -168,15 +95,15 @@ export function HomeScreen() { const [page, setPage] = useState(1); const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE); const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles(); - const deleteFile = useDeleteFile(); const freeLocalSpace = useFreeLocalSpace(); const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(''); const debouncedSearch = useDebounce(searchQuery, 250); - const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true }); + const [filters, setFilters] = useState({ name: true, ocrText: true }); + const [mediaFilter, setMediaFilter] = useState('all'); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [groupByTags, setGroupByTags] = useState(false); + const [numColumns, setNumColumns] = useState(3); const [tagModalVisible, setTagModalVisible] = useState(false); const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag'); const [tagInput, setTagInput] = useState(''); @@ -185,11 +112,18 @@ export function HomeScreen() { const { data: foldersData } = useFolders(); const [moveModalVisible, setMoveModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false); + const [uploadModalVisible, setUploadModalVisible] = useState(false); const [globalSyncMode, setGlobalSyncMode] = useState(() => safDirectory.getGlobalSyncMode()); const [globalSyncCellular, setGlobalSyncCellular] = useState(() => safDirectory.getGlobalSyncCellular()); 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(() => { if (isFetching) return; const total = data?.meta?.total ?? 0; @@ -223,6 +157,9 @@ export function HomeScreen() { pendingCount={pendingCount} onPress={() => navigation.navigate('SyncDetail')} /> + setUploadModalVisible(true)} style={{ padding: 8 }}> + + setSettingsModalVisible(true)} style={{ marginRight: 4, padding: 8 }}> @@ -240,18 +177,42 @@ export function HomeScreen() { [files, debouncedSearch, filters] ); - const groups = useMemo( - () => groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles), - [filteredFiles, groupByTags] - ); + const mediaFilteredFiles = useMemo(() => { + if (mediaFilter === 'all') return filteredFiles; + return filteredFiles.filter((f) => { + const mt = (f.mimeType ?? '').toLowerCase(); + if (mediaFilter === 'documents') return mt.startsWith('application/') || mt.startsWith('text/'); + if (mediaFilter === 'photos-videos') return mt.startsWith('image/') || mt.startsWith('video/'); + return true; + }); + }, [filteredFiles, mediaFilter]); - const groupKeys = useMemo(() => Object.keys(groups), [groups]); + const sortedFiles = useMemo(() => + [...mediaFilteredFiles].sort((a, b) => { + const da = parseBackendDate(a.createdAt); + const db = parseBackendDate(b.createdAt); + return (db?.getTime() ?? 0) - (da?.getTime() ?? 0); + }), + [mediaFilteredFiles] + ); const fileIdToIndex = useMemo(() => { const map = new Map(); - filteredFiles.forEach((f, i) => map.set(f.id, i)); + sortedFiles.forEach((f, i) => map.set(f.id, i)); return map; - }, [filteredFiles]); + }, [sortedFiles]); + + const pinchGesture = useMemo(() => + Gesture.Pinch() + .onEnd((event) => { + if (event.scale > 1.2) { + setNumColumns(prev => Math.max(2, prev - 1)); + } else if (event.scale < 0.8) { + setNumColumns(prev => Math.min(6, prev + 1)); + } + }), + [] + ); const toggleSelection = useCallback((id: string) => { setSelectedIds((prev) => { @@ -314,7 +275,7 @@ export function HomeScreen() { }, }); Alert.alert('Supprimer', `Supprimer ${label} ?`, options); - }, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]); + }, [selectedIds, files, freeLocalSpace, queryClient]); const handleEdit = useCallback(() => { const ids = Array.from(selectedIds); @@ -402,18 +363,18 @@ export function HomeScreen() { navigation.navigate('Folder', { folderId: file.id, folderName: file.name }); } else { const deviceFilesMap: Record = {}; - for (const f of filteredFiles) { + for (const f of sortedFiles) { if (f.isDeviceFile && f.localUri) { deviceFilesMap[f.id] = { localUri: f.localUri, name: f.name, mimeType: f.mimeType, createdAt: f.createdAt }; } } navigation.navigate('FileDetail', { - fileIds: filteredFiles.map((f) => f.id), + fileIds: sortedFiles.map((f) => f.id), initialIndex: fileIdToIndex.get(file.id) ?? 0, deviceFiles: Object.keys(deviceFilesMap).length > 0 ? deviceFilesMap : undefined, }); } - }, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]); + }, [selectionMode, toggleSelection, navigation, sortedFiles, fileIdToIndex]); const handleItemLongPress = useCallback((file: UnifiedFileItem) => { if (!selectionMode) { @@ -421,26 +382,15 @@ export function HomeScreen() { } }, [selectionMode, toggleSelection]); - const renderSection = useCallback(({ item: groupKey }: { item: string }) => { - const groupFiles = groups[groupKey]; - if (!groupFiles) return null; - const label = groupByTags ? groupKey : formatDateLabel(groupKey); - return ( - - - {groupByTags && } - {label} - {groupFiles.length} - - - - ); - }, [groups, groupByTags, selectedIds, handleItemPress, handleItemLongPress]); + const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => ( + + ), [itemSize, selectedIds, handleItemPress, handleItemLongPress]); if (isLoading) { return ( @@ -495,46 +445,52 @@ export function HomeScreen() { )} - item} - contentContainerStyle={styles.list} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - refreshControl={ - - } - onEndReached={loadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={ - hasMore ? ( - - {isFetching ? ( - - ) : ( - - Charger plus ({loadedFiles}/{totalFiles}) + + + item.id} + numColumns={numColumns} + columnWrapperStyle={{ gap: ITEM_GAP }} + contentContainerStyle={styles.list} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + hasMore ? ( + + {isFetching ? ( + + ) : ( + + Charger plus ({loadedFiles}/{totalFiles}) + + )} + + ) : loadedFiles > 0 ? ( + {loadedFiles} fichier{loadedFiles > 1 ? 's' : ''} + ) : null + } + ListEmptyComponent={ + + + {debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'} - )} - - ) : loadedFiles > 0 ? ( - {loadedFiles} fichier{loadedFiles > 1 ? 's' : ''} - ) : null - } - ListEmptyComponent={ - - - {debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'} - - - } - renderItem={renderSection} - /> + + } + renderItem={renderItem} + /> + + {!selectionMode && ( setSearchQuery('')} filters={filters} onFiltersChange={setFilters} - groupByTags={groupByTags} - onGroupToggle={() => setGroupByTags(!groupByTags)} + mediaFilter={mediaFilter} + onMediaFilterChange={setMediaFilter} bottomPadding={keyboardOpen ? insets.bottom+8 : 0} /> )} @@ -611,14 +567,6 @@ export function HomeScreen() { Accueil - navigation.navigate('Upload')} - > - - Upload - - navigation.navigate('Scan')} @@ -686,6 +634,12 @@ export function HomeScreen() { + setUploadModalVisible(false)} + onUploadComplete={handleUploadComplete} + /> + setSettingsModalVisible(false)} @@ -770,8 +724,11 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#666', }, + listWrapper: { + flex: 1, + }, list: { - padding: 15, + padding: PADDING_H, paddingBottom: 80, }, empty: { @@ -782,35 +739,8 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#999', }, - section: { - marginBottom: 24, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '700', - color: '#333', - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - gap: 6, - }, - sectionIcon: { - marginTop: 2, - }, - sectionCount: { - fontSize: 14, - color: '#999', - fontWeight: '400', - }, - grid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - }, gridItem: { - width: ITEM_SIZE, + marginBottom: ITEM_GAP, }, gridItemSelected: { opacity: 0.85, @@ -820,7 +750,7 @@ const styles = StyleSheet.create({ top: 0, left: 0, right: 0, - bottom: 18, + bottom: 0, justifyContent: 'flex-start', alignItems: 'flex-end', padding: 4, @@ -833,12 +763,6 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - fileName: { - fontSize: 11, - color: '#666', - marginTop: 4, - textAlign: 'center', - }, bottomNav: { flexDirection: 'row', justifyContent: 'space-around', diff --git a/mobile/components/SearchBar.tsx b/mobile/components/SearchBar.tsx index 6116f35..be874eb 100644 --- a/mobile/components/SearchBar.tsx +++ b/mobile/components/SearchBar.tsx @@ -8,27 +8,51 @@ type IconName = ComponentProps['name']; export interface SearchFilters { name: boolean; ocrText: boolean; - tags: boolean; } +export type MediaFilter = 'all' | 'documents' | 'photos-videos'; + interface SearchBarProps { query: string; onQueryChange: (q: string) => void; onClear: () => void; filters: SearchFilters; onFiltersChange: (f: SearchFilters) => void; - groupByTags: boolean; - onGroupToggle: () => void; + mediaFilter: MediaFilter; + onMediaFilterChange: (f: MediaFilter) => void; bottomPadding?: number; } const FILTER_OPTIONS: { key: keyof SearchFilters; label: string; icon: IconName }[] = [ { key: 'name', label: 'Nom', icon: 'drive-file-rename-outline' }, { key: 'ocrText', label: 'Texte OCR', icon: 'document-scanner' }, - { key: 'tags', label: 'Tags', icon: 'label-outline' }, ]; -export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, groupByTags, onGroupToggle, bottomPadding = 0 }: SearchBarProps) { +const MEDIA_OPTIONS: { key: MediaFilter; label: string; icon: IconName }[] = [ + { key: 'all', label: 'Tous', icon: 'filter-none' }, + { key: 'documents', label: 'Documents', icon: 'description' }, + { key: 'photos-videos', label: 'Photos', icon: 'photo-library' }, +]; + +const MEDIA_ICONS: Record = { + all: 'filter-none', + documents: 'description', + 'photos-videos': 'photo-library', +}; + +const MEDIA_LABELS: Record = { + all: 'Tous', + documents: 'Documents', + 'photos-videos': 'Photos', +}; + +function cycleMediaFilter(current: MediaFilter): MediaFilter { + if (current === 'all') return 'documents'; + if (current === 'documents') return 'photos-videos'; + return 'all'; +} + +export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, mediaFilter, onMediaFilterChange, bottomPadding = 0 }: SearchBarProps) { const animatedHeight = useRef(new Animated.Value(0)).current; const [panelOpen, setPanelOpen] = React.useState(false); @@ -43,14 +67,14 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha const panelMaxHeight = animatedHeight.interpolate({ inputRange: [0, 1], - outputRange: [0, 100], + outputRange: [0, 120], }); const toggleFilter = (key: keyof SearchFilters) => { onFiltersChange({ ...filters, [key]: !filters[key] }); }; - const hasActiveFilter = filters.name || filters.ocrText || filters.tags; + const hasActiveFilter = filters.name || filters.ocrText; return ( @@ -74,7 +98,7 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha setPanelOpen(!panelOpen)} > onMediaFilterChange(cycleMediaFilter(mediaFilter))} > @@ -113,6 +137,23 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha ))} + + {MEDIA_OPTIONS.map((opt) => ( + onMediaFilterChange(opt.key)} + > + + + {opt.label} + + + ))} ); @@ -151,7 +192,7 @@ const styles = StyleSheet.create({ clearBtn: { padding: 4, }, - filterBtn: { + iconBtn: { width: 40, height: 40, borderRadius: 10, @@ -161,29 +202,23 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: '#fff', }, - filterBtnActive: { - backgroundColor: '#1976D2', - }, - groupBtn: { - width: 40, - height: 40, - borderRadius: 10, - borderWidth: 1, - borderColor: '#1976D2', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#fff', - }, - groupBtnActive: { + iconBtnActive: { backgroundColor: '#1976D2', }, filterPanel: { flexDirection: 'row', + flexWrap: 'wrap', paddingHorizontal: 12, paddingBottom: 10, gap: 8, overflow: 'hidden', }, + mediaDivider: { + width: '100%', + height: 1, + backgroundColor: '#e0e0e0', + marginVertical: 4, + }, filterChip: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/app/upload.tsx b/mobile/components/UploadModal.tsx similarity index 56% rename from mobile/app/upload.tsx rename to mobile/components/UploadModal.tsx index e815a79..2dd78a7 100644 --- a/mobile/app/upload.tsx +++ b/mobile/components/UploadModal.tsx @@ -1,14 +1,21 @@ -import React, { useState, useCallback } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import React, { useState, useCallback, useEffect } 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 } from '../hooks/usePollOcr'; -import { UploadProgress } from '../components/UploadProgress'; -import { UploadError, HttpError } from '../types'; +import { UploadProgress } from './UploadProgress'; +import { UploadError } from '../types'; import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; +interface UploadModalProps { + visible: boolean; + onClose: () => void; + onUploadComplete?: () => void; +} + function getUploadErrorMessage(err: UploadError): string { switch (err.status) { case 400: @@ -26,14 +33,16 @@ function getUploadErrorMessage(err: UploadError): string { } } -export function UploadScreen() { - const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'processing' | 'success' | 'error'>('idle'); +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) { @@ -68,7 +77,7 @@ export function UploadScreen() { }, []); const doUpload = async (files: UploadFile[]) => { - setUploadStatus('uploading'); + setStatus('uploading'); setUploadedCount(0); setTotalCount(files.length); setError(undefined); @@ -76,7 +85,7 @@ export function UploadScreen() { try { const deduped = await checkDupBeforeUpload(files); if (deduped.length === 0) { - setUploadStatus('success'); + setStatus('success'); setUploadedCount(files.length); return; } @@ -86,7 +95,7 @@ export function UploadScreen() { setUploadedCount(response.uploaded.length); if (response.uploaded.length > 0) { - setUploadStatus('processing'); + setStatus('processing'); const results = await Promise.allSettled( response.uploaded.map((f) => pollOcr(f.id)), ); @@ -94,21 +103,21 @@ export function UploadScreen() { (r) => r.status === 'fulfilled' && r.value.status === 'completed', ).length; if (completed > 0) { - setUploadStatus('success'); + setStatus('success'); } } if (response.errors.length > 0) { - setUploadStatus('error'); + setStatus('error'); const messages = response.errors.map((e) => getUploadErrorMessage(e)); setError( `${response.uploaded.length}/${totalCount} uploadés.\n${messages.join('\n')}` ); } else { - setUploadStatus('success'); + setStatus('success'); } } catch (err) { - setUploadStatus('error'); + setStatus('error'); if (err instanceof UploadError) { setError(getUploadErrorMessage(err)); } else if (err instanceof Error) { @@ -121,8 +130,8 @@ export function UploadScreen() { const pickImages = async () => { try { - const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (status !== 'granted') { + const { status: perm } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (perm !== 'granted') { Alert.alert('Permission requise', "L'accès à la galerie est nécessaire pour sélectionner des photos."); return; } @@ -168,46 +177,143 @@ export function UploadScreen() { } }; + 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 ( - - + + + {}}> + - - Sélectionner des photos - + + Ajouter des fichiers + {canClose && ( + + + + )} + - - Sélectionner des documents + + + + + Sélectionner des photos + + + + + Sélectionner des documents + + - + ); } const styles = StyleSheet.create({ - container: { + overlay: { flex: 1, - padding: 16, - backgroundColor: '#fff', + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', }, - uploadButton: { + container: { + backgroundColor: '#fff', + borderRadius: 16, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 20, + width: '85%', + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#ddd', + alignSelf: 'center', + marginBottom: 16, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#333', + }, + closeBtn: { + padding: 4, + }, + photoButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', backgroundColor: '#1976D2', padding: 16, borderRadius: 8, - alignItems: 'center', marginBottom: 12, + gap: 8, }, docButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', backgroundColor: '#4CAF50', padding: 16, borderRadius: 8, - alignItems: 'center', + gap: 8, }, - uploadText: { + buttonText: { color: '#fff', fontSize: 16, fontWeight: '600',