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', + }, });