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',