diff --git a/mobile/App.tsx b/mobile/App.tsx
index 38e2ba8..bcc143a 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -10,6 +10,7 @@ import { SearchScreen } from './app/search';
import { BatchReviewScreen } from './app/batch-review';
import { PendingReviewScreen } from './app/pending-review';
import { FileDetailScreen } from './app/file-detail';
+import { FileEditScreen } from './app/file-edit';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
@@ -35,6 +36,7 @@ export default function App() {
component={FileDetailScreen}
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }}
/>
+
diff --git a/mobile/app/file-edit.tsx b/mobile/app/file-edit.tsx
new file mode 100644
index 0000000..6ef59f3
--- /dev/null
+++ b/mobile/app/file-edit.tsx
@@ -0,0 +1,361 @@
+import React, { useState, useCallback } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ FlatList,
+ TouchableOpacity,
+ Image,
+ TextInput,
+ Alert,
+ ActivityIndicator,
+ Dimensions,
+} from 'react-native';
+import { RouteProp, useRoute, useNavigation } from '@react-navigation/native';
+import { MaterialIcons } from '@expo/vector-icons';
+import { useFile, useFileImage, useAddTags } from '../hooks/useFiles';
+import { usePdfGeneration } from '../hooks/usePdfGeneration';
+import { useUpload } from '../hooks/useUpload';
+import { TagChip } from '../components/TagChip';
+import { FileThumbnail } from '../components/FileThumbnail';
+import { FileItem } from '../types';
+
+const NUM_COLUMNS = 3;
+const SCREEN_WIDTH = Dimensions.get('window').width;
+const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
+
+type FileEditRouteParams = {
+ FileEdit: { fileIds: string[] };
+};
+
+interface FileEditItemProps {
+ fileId: string;
+}
+
+function FileEditItem({ fileId }: FileEditItemProps) {
+ const { data: fileData, isLoading: fileLoading } = useFile(fileId);
+ const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
+ const file = fileData as any;
+ const uri = imageData?.data?.url;
+ const isLoading = fileLoading || imageLoading;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {uri && file?.mimeType?.startsWith('image/') ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function FileEditScreen() {
+ const route = useRoute>();
+ const navigation = useNavigation();
+ const { fileIds } = route.params;
+
+ const addTags = useAddTags();
+ const { generatePdf, generating, progress } = usePdfGeneration();
+ const upload = useUpload();
+
+ const [tagInput, setTagInput] = useState('');
+ const [pendingTags, setPendingTags] = useState([]);
+ const [uploading, setUploading] = useState(false);
+
+ const handleAddTag = () => {
+ const tag = tagInput.trim().toLowerCase();
+ if (!tag || pendingTags.includes(tag)) return;
+ setPendingTags((prev) => [...prev, tag]);
+ setTagInput('');
+ };
+
+ const handleRemoveTag = (tag: string) => {
+ setPendingTags((prev) => prev.filter((t) => t !== tag));
+ };
+
+ const handleApplyTags = useCallback(async () => {
+ if (pendingTags.length === 0) return;
+ for (const fileId of fileIds) {
+ await addTags.mutateAsync({ fileId, tags: pendingTags });
+ }
+ Alert.alert('Succès', `${pendingTags.length} tag${pendingTags.length > 1 ? 's' : ''} ajouté${pendingTags.length > 1 ? 's' : ''}`);
+ setPendingTags([]);
+ }, [pendingTags, fileIds, addTags]);
+
+ const handleGeneratePdf = useCallback(async () => {
+ if (fileIds.length === 0) return;
+
+ setUploading(true);
+ try {
+ const imageUris: { uri: string }[] = [];
+
+ for (const fileId of fileIds) {
+ const response = await fetch(`${process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'}/files/${fileId}`);
+ const data = await response.json();
+ const url = data?.data?.url;
+ if (url) {
+ imageUris.push({ uri: url });
+ }
+ }
+
+ if (imageUris.length === 0) {
+ Alert.alert('Erreur', 'Aucune image trouvée pour la génération du PDF');
+ setUploading(false);
+ return;
+ }
+
+ const pdfUri = await generatePdf(imageUris);
+ if (!pdfUri) {
+ Alert.alert('Erreur', 'Échec de la génération du PDF');
+ setUploading(false);
+ return;
+ }
+
+ Alert.alert(
+ 'PDF généré',
+ 'Voulez-vous uploader le fichier ?',
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Uploader',
+ onPress: async () => {
+ try {
+ const pdfName = `document_${Date.now()}.pdf`;
+ const pdfUriClean = pdfUri.startsWith('file://') ? pdfUri : 'file://' + pdfUri;
+ await upload.mutateAsync([
+ { uri: pdfUriClean, type: 'application/pdf', name: pdfName },
+ ]);
+ Alert.alert('Succès', 'PDF uploadé avec succès', [
+ { text: 'OK', onPress: () => navigation.goBack() },
+ ]);
+ } catch (e: any) {
+ const msg = e?.message || e?.toString() || 'Erreur inconnue';
+ Alert.alert('Erreur', `Échec de l'upload du PDF: ${msg}`);
+ }
+ },
+ },
+ ]
+ );
+ } finally {
+ setUploading(false);
+ }
+ }, [fileIds, generatePdf, upload, navigation]);
+
+ const isLoading = generating || uploading;
+
+ return (
+
+
+ Édition
+
+ {fileIds.length} fichier{fileIds.length > 1 ? 's' : ''} sélectionné{fileIds.length > 1 ? 's' : ''}
+
+
+
+ item}
+ contentContainerStyle={styles.grid}
+ columnWrapperStyle={styles.gridRow}
+ renderItem={({ item }) => }
+ />
+
+
+ Tags
+
+ {pendingTags.map((tag) => (
+ handleRemoveTag(tag)}>
+ handleRemoveTag(tag)} />
+
+ ))}
+
+
+
+
+
+
+
+ {pendingTags.length > 0 && (
+
+ {addTags.isPending ? (
+
+ ) : (
+ Appliquer les tags
+ )}
+
+ )}
+
+
+
+ {(generating || uploading) && (
+
+
+
+ {generating ? `Génération du PDF... ${progress}%` : 'Upload en cours...'}
+
+
+ )}
+
+ {generating ? (
+
+ ) : (
+
+ )}
+ Créer un PDF
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ header: {
+ padding: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: '#e0e0e0',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 4,
+ },
+ subtitle: {
+ fontSize: 14,
+ color: '#666',
+ },
+ grid: {
+ padding: 16,
+ },
+ gridRow: {
+ gap: 6,
+ },
+ gridItem: {
+ width: ITEM_SIZE,
+ height: ITEM_SIZE,
+ borderRadius: 6,
+ overflow: 'hidden',
+ backgroundColor: '#f0f0f0',
+ marginBottom: 6,
+ },
+ gridItemLoading: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ thumb: {
+ width: '100%',
+ height: '100%',
+ },
+ tagSection: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ tagRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 6,
+ marginBottom: 8,
+ },
+ tagChip: {
+ marginRight: 2,
+ },
+ tagInputRow: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ tagInput: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ borderRadius: 8,
+ padding: 10,
+ fontSize: 14,
+ },
+ tagAddBtn: {
+ width: 40,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: '#1976D2',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ applyTagsBtn: {
+ marginTop: 10,
+ backgroundColor: '#4CAF50',
+ paddingVertical: 10,
+ borderRadius: 8,
+ alignItems: 'center',
+ },
+ applyTagsText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ footer: {
+ padding: 16,
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ gap: 8,
+ },
+ progressRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ progressText: {
+ fontSize: 14,
+ color: '#666',
+ },
+ pdfBtn: {
+ flexDirection: 'row',
+ backgroundColor: '#1976D2',
+ paddingHorizontal: 24,
+ paddingVertical: 14,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ },
+ pdfBtnDisabled: {
+ opacity: 0.6,
+ },
+ pdfBtnText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 8fcb005..2fe40a0 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -1,10 +1,10 @@
-import React, { useState, useMemo, useEffect } from 'react';
-import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
+import React, { useState, useMemo, useEffect, useCallback } from 'react';
+import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert } from 'react-native';
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 { useFiles, useFileImage } from '../hooks/useFiles';
+import { useFiles, useFileImage, useDeleteFile } from '../hooks/useFiles';
import { FileItem } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail';
@@ -18,6 +18,7 @@ type RootStackParamList = {
Upload: undefined;
Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number };
+ FileEdit: { fileIds: string[] };
};
type NavigationProp = NativeStackNavigationProp;
@@ -55,11 +56,17 @@ function formatDateLabel(key: string): string {
return key.charAt(0).toUpperCase() + key.slice(1);
}
-function FileGridItem({ file, onPress }: { file: FileItem; onPress?: () => void }) {
+function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
const { data, isLoading } = useFileImage(file.id);
return (
-
+
void
size={ITEM_SIZE}
isLoading={isLoading}
/>
+ {selected && (
+
+
+
+
+
+ )}
{file.name}
);
@@ -96,9 +110,11 @@ export function HomeScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { data, isLoading, error } = useFiles();
+ const deleteFile = useDeleteFile();
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true });
const [keyboardOpen, setKeyboardOpen] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -106,6 +122,8 @@ export function HomeScreen() {
return () => { show.remove(); hide.remove(); };
}, []);
+ const selectionMode = selectedIds.size > 0;
+
const files = data?.data ?? [];
const filteredFiles = useMemo(
@@ -122,6 +140,67 @@ export function HomeScreen() {
return map;
}, [filteredFiles]);
+ const toggleSelection = useCallback((id: string) => {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setSelectedIds(new Set());
+ }, []);
+
+ const handleDelete = useCallback(() => {
+ const count = selectedIds.size;
+ if (count === 0) return;
+ const label = count === 1 ? 'ce fichier' : `ces ${count} fichiers`;
+ Alert.alert(
+ 'Supprimer',
+ `Supprimer ${label} ?`,
+ [
+ { text: 'Annuler', style: 'cancel' },
+ {
+ text: 'Supprimer',
+ style: 'destructive',
+ onPress: async () => {
+ const ids = Array.from(selectedIds);
+ for (const id of ids) {
+ await deleteFile.mutateAsync(id);
+ }
+ setSelectedIds(new Set());
+ },
+ },
+ ]
+ );
+ }, [selectedIds, deleteFile]);
+
+ const handleEdit = useCallback(() => {
+ const ids = Array.from(selectedIds);
+ if (ids.length === 0) return;
+ navigation.navigate('FileEdit', { fileIds: ids });
+ setSelectedIds(new Set());
+ }, [selectedIds, navigation]);
+
+ const handleItemPress = useCallback((file: FileItem) => {
+ if (selectionMode) {
+ toggleSelection(file.id);
+ } else {
+ navigation.navigate('FileDetail', {
+ fileIds: filteredFiles.map((f) => f.id),
+ initialIndex: fileIdToIndex.get(file.id) ?? 0,
+ });
+ }
+ }, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
+
+ const handleItemLongPress = useCallback((file: FileItem) => {
+ if (!selectionMode) {
+ toggleSelection(file.id);
+ }
+ }, [selectionMode, toggleSelection]);
+
if (isLoading) {
return (
@@ -167,12 +246,9 @@ export function HomeScreen() {
- navigation.navigate('FileDetail', {
- fileIds: filteredFiles.map((f) => f.id),
- initialIndex: fileIdToIndex.get(file.id) ?? 0,
- })
- }
+ selected={selectedIds.has(file.id)}
+ onPress={() => handleItemPress(file)}
+ onLongPress={() => handleItemLongPress(file)}
/>
))}
@@ -181,37 +257,71 @@ export function HomeScreen() {
}}
/>
- setSearchQuery('')}
- filters={filters}
- onFiltersChange={setFilters}
- bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
- />
+ {!selectionMode && (
+ setSearchQuery('')}
+ filters={filters}
+ onFiltersChange={setFilters}
+ bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
+ />
+ )}
-
- {}}>
-
- Accueil
-
+ {selectionMode ? (
+
+
+
+
+
+
+ {selectedIds.size} sélectionnée{selectedIds.size > 1 ? 's' : ''}
+
+
+ Tout
+
+
+
+
+
+ Supprimer
+
+
+
+ Éditer
+
+
+
+ ) : (
+
+ {}}>
+
+ Accueil
+
- navigation.navigate('Upload')}
- >
-
- Upload
-
+ navigation.navigate('Upload')}
+ >
+
+ Upload
+
- navigation.navigate('Scan')}
- >
-
- Scan
-
-
+ navigation.navigate('Scan')}
+ >
+
+ Scan
+
+
+ )}
);
}
@@ -259,6 +369,27 @@ const styles = StyleSheet.create({
gridItem: {
width: ITEM_SIZE,
},
+ gridItemSelected: {
+ opacity: 0.85,
+ },
+ selectedOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 18,
+ justifyContent: 'flex-start',
+ alignItems: 'flex-end',
+ padding: 4,
+ },
+ checkCircle: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: '#1976D2',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
fileName: {
fontSize: 11,
color: '#666',
@@ -282,4 +413,60 @@ const styles = StyleSheet.create({
fontSize: 16,
color: '#1976D2',
},
+ selectionBar: {
+ backgroundColor: '#fff',
+ borderTopWidth: 1,
+ borderTopColor: '#e0e0e0',
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ },
+ selectionHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 12,
+ },
+ cancelBtn: {
+ padding: 4,
+ },
+ selectionCount: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ },
+ selectAllBtn: {
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+ selectAllText: {
+ fontSize: 14,
+ color: '#1976D2',
+ fontWeight: '600',
+ },
+ selectionActions: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ selectionActionBtn: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 12,
+ borderRadius: 8,
+ gap: 6,
+ borderWidth: 1.5,
+ },
+ deleteActionBtn: {
+ borderColor: '#F44336',
+ backgroundColor: 'transparent',
+ },
+ editActionBtn: {
+ borderColor: '#1976D2',
+ backgroundColor: 'transparent',
+ },
+ selectionActionText: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
});