diff --git a/mobile/App.tsx b/mobile/App.tsx index b64b98b..9be5f03 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -8,6 +8,7 @@ import { UploadScreen } from './app/upload'; import { ScanScreen } from './app/scan'; import { SearchScreen } from './app/search'; import { BatchReviewScreen } from './app/batch-review'; +import { PendingReviewScreen } from './app/pending-review'; const Stack = createNativeStackNavigator(); const queryClient = new QueryClient(); @@ -22,6 +23,7 @@ export default function App() { + diff --git a/mobile/app/batch-review.tsx b/mobile/app/batch-review.tsx index 605b76d..4c06cd4 100644 --- a/mobile/app/batch-review.tsx +++ b/mobile/app/batch-review.tsx @@ -1,24 +1,27 @@ -import React, { useState } from 'react'; -import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator, Share } from 'react-native'; +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator } from 'react-native'; import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useBatchStore } from '../hooks/useBatchStore'; import { usePdfGeneration } from '../hooks/usePdfGeneration'; -type BatchReviewRouteParams = { +type RootStackParamList = { + Home: undefined; BatchReview: { batchId: string }; + PendingReview: { batchId: string; photoIds: string[] }; }; +type NavigationProp = NativeStackNavigationProp; +type BatchReviewRouteParams = { BatchReview: { batchId: string } }; + export function BatchReviewScreen() { const route = useRoute>(); - const navigation = useNavigation(); - const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore(); - const { generatePdf, generating } = usePdfGeneration(); + const navigation = useNavigation(); + const { getBatch, removePhotoFromBatch } = useBatchStore(); const batch = getBatch(route.params.batchId); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [editingTags, setEditingTags] = useState(false); - const [tagInput, setTagInput] = useState(''); if (!batch) { return ( @@ -37,44 +40,39 @@ export function BatchReviewScreen() { }); }; - const selectedPhotos = batch.photos.filter((p) => selectedIds.has(p.id)); const selectedCount = selectedIds.size; - const handleGeneratePdf = async () => { - const photos = selectedCount > 0 ? selectedPhotos : batch.photos; - if (photos.length === 0) return; - - const uri = await generatePdf(photos); - if (uri) { - Alert.alert('PDF généré', 'Voulez-vous partager le fichier ?', [ + const handleDelete = () => { + if (selectedCount === 0) return; + const label = selectedCount === 1 ? 'cette photo' : `ces ${selectedCount} photos`; + Alert.alert( + 'Supprimer', + `Retirer ${label} du lot ?`, + [ { text: 'Annuler', style: 'cancel' }, { - text: 'Partager', + text: 'Supprimer', + style: 'destructive', onPress: () => { - Share.share({ url: uri, title: batch.name }); + selectedIds.forEach((id) => removePhotoFromBatch(batch.id, id)); + setSelectedIds(new Set()); }, }, - ]); - } else { - Alert.alert('Erreur', 'Impossible de générer le PDF'); - } + ] + ); }; - const handleAddTag = () => { - const tag = tagInput.trim().toLowerCase(); - if (!tag || batch.tags.includes(tag)) return; - addTagToBatch(batch.id, tag); - setTagInput(''); + const handleGroup = () => { + if (selectedCount === 0) return; + const ids = Array.from(selectedIds); + navigation.navigate('PendingReview', { batchId: batch.id, photoIds: ids }); }; const formatDate = (iso: string) => { const d = new Date(iso); return d.toLocaleDateString('fr-FR', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', }); }; @@ -111,36 +109,25 @@ export function BatchReviewScreen() { /> - - - {batch.tags.map((tag) => ( - removeTagFromBatch(batch.id, tag)} - > - {tag} ✕ - - ))} - - Tags disponibles avec le backend (bientôt) - + + {selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Touchez une photo pour sélectionner'} + - - {selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Toutes les photos'} - + + 🗑 Supprimer + - {generating ? ( - - ) : ( - 📄 Générer PDF - )} + 📦 Regrouper @@ -212,47 +199,29 @@ const styles = StyleSheet.create({ borderTopColor: '#e0e0e0', gap: 12, }, - tagSection: { - gap: 8, - }, - tagRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - }, - tagChip: { - backgroundColor: '#E3F2FD', - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - }, - tagText: { - fontSize: 13, - color: '#1976D2', - }, - disabledNote: { - fontSize: 12, - color: '#999', - fontStyle: 'italic', - }, - actions: { - gap: 8, - alignItems: 'center', - }, selectionInfo: { fontSize: 14, color: '#666', + textAlign: 'center', }, - actionButton: { - backgroundColor: '#1976D2', - paddingHorizontal: 24, + actions: { + flexDirection: 'row', + gap: 12, + }, + actionBtn: { + flex: 1, paddingVertical: 12, borderRadius: 8, - width: '100%', alignItems: 'center', }, - actionButtonDisabled: { - opacity: 0.6, + actionBtnDisabled: { + opacity: 0.4, + }, + deleteBtn: { + backgroundColor: '#F44336', + }, + groupBtn: { + backgroundColor: '#4CAF50', }, actionText: { color: '#fff', diff --git a/mobile/app/pending-review.tsx b/mobile/app/pending-review.tsx new file mode 100644 index 0000000..bf3a2ef --- /dev/null +++ b/mobile/app/pending-review.tsx @@ -0,0 +1,339 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, TextInput, Alert, ActivityIndicator } from 'react-native'; +import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; +import { useBatchStore } from '../hooks/useBatchStore'; +import { usePdfGeneration } from '../hooks/usePdfGeneration'; +import { useUpload } from '../hooks/useUpload'; +import { CapturedPhoto } from '../types'; + +type PendingReviewRouteParams = { + PendingReview: { batchId: string; photoIds: string[] }; +}; + +export function PendingReviewScreen() { + const route = useRoute>(); + const navigation = useNavigation(); + const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore(); + const { generatePdf, generating, progress } = usePdfGeneration(); + const upload = useUpload(); + + const batch = getBatch(route.params.batchId); + + const initialPhotos = (batch?.photos ?? []).filter((p) => + route.params.photoIds.includes(p.id) + ); + + const [photos, setPhotos] = useState(initialPhotos); + const [batchTags, setBatchTags] = useState(batch?.tags ?? []); + const [tagInput, setTagInput] = useState(''); + + const moveUp = useCallback((index: number) => { + if (index === 0) return; + setPhotos((prev) => { + const next = [...prev]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + return next; + }); + }, []); + + const moveDown = useCallback((index: number) => { + if (index >= photos.length - 1) return; + setPhotos((prev) => { + const next = [...prev]; + [next[index], next[index + 1]] = [next[index + 1], next[index]]; + return next; + }); + }, [photos.length]); + + const handleAddTag = () => { + const tag = tagInput.trim().toLowerCase(); + if (!tag || batchTags.includes(tag)) return; + setBatchTags((prev) => [...prev, tag]); + addTagToBatch(route.params.batchId, tag); + setTagInput(''); + }; + + const handleRemoveTag = (tag: string) => { + setBatchTags((prev) => prev.filter((t) => t !== tag)); + removeTagFromBatch(route.params.batchId, tag); + }; + + const handleFinalize = async () => { + if (photos.length === 0) return; + + const pdfUri = await generatePdf(photos.map((p) => ({ uri: p.uri }))); + if (!pdfUri) { + Alert.alert('Erreur', 'Échec de la génération du PDF'); + return; + } + + Alert.alert( + 'PDF généré', + 'Voulez-vous uploader le fichier ?', + [ + { text: 'Annuler', style: 'cancel', onPress: () => navigation.goBack() }, + { + text: 'Uploader', + onPress: async () => { + try { + await upload.mutateAsync([ + { + uri: 'file://' + pdfUri, + type: 'application/pdf', + name: `${batch?.name ?? 'document'}.pdf`, + }, + ]); + Alert.alert('Succès', 'PDF uploadé avec succès', [ + { text: 'OK', onPress: () => navigation.navigate('Home' as never) }, + ]); + } catch { + Alert.alert('Erreur', "Échec de l'upload du PDF"); + } + }, + }, + ] + ); + }; + + if (!batch) { + return ( + + Lot introuvable + + ); + } + + return ( + + + Réorganiser les photos + {photos.length} photo{photos.length > 1 ? 's' : ''} + + + item.id} + contentContainerStyle={styles.list} + renderItem={({ item, index }) => ( + + + #{index + 1} + + moveUp(index)} + disabled={index === 0} + > + + + = photos.length - 1 && styles.arrowDisabled]} + onPress={() => moveDown(index)} + disabled={index >= photos.length - 1} + > + + + + + )} + /> + + + Tags + + {batchTags.map((tag) => ( + handleRemoveTag(tag)}> + {tag} ✕ + + ))} + + + + + + + + + + + + {generating && ( + + + Génération du PDF... {progress}% + + )} + + {generating ? ( + + ) : ( + 📄 Finaliser et uploader le PDF + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + color: '#666', + }, + header: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + title: { + fontSize: 20, + fontWeight: '700', + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: '#666', + }, + list: { + padding: 16, + }, + photoRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + backgroundColor: '#f9f9f9', + borderRadius: 8, + padding: 8, + }, + thumb: { + width: 60, + height: 60, + borderRadius: 6, + backgroundColor: '#e0e0e0', + }, + photoIndex: { + marginLeft: 12, + fontSize: 16, + fontWeight: '600', + color: '#333', + flex: 1, + }, + arrows: { + gap: 4, + }, + arrowBtn: { + width: 36, + height: 28, + borderRadius: 4, + backgroundColor: '#e0e0e0', + justifyContent: 'center', + alignItems: 'center', + }, + arrowDisabled: { + opacity: 0.3, + }, + arrowText: { + fontSize: 14, + color: '#333', + }, + tagSection: { + padding: 16, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }, + tagRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginBottom: 8, + }, + tagChip: { + backgroundColor: '#E3F2FD', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + tagText: { + fontSize: 13, + color: '#1976D2', + }, + 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', + }, + tagAddText: { + color: '#fff', + fontSize: 20, + fontWeight: '700', + }, + footer: { + padding: 16, + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + gap: 8, + }, + progressRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + progressText: { + fontSize: 14, + color: '#666', + }, + finalizeBtn: { + backgroundColor: '#1976D2', + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + }, + finalizeBtnDisabled: { + opacity: 0.6, + }, + finalizeText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/mobile/hooks/useBatchStore.ts b/mobile/hooks/useBatchStore.ts index 90218f8..878df8a 100644 --- a/mobile/hooks/useBatchStore.ts +++ b/mobile/hooks/useBatchStore.ts @@ -48,6 +48,17 @@ export function useBatchStore() { setBatchIds((batchIds ?? []).filter((id) => id !== batchId)); }, [batchIds, setBatchIds]); + const removePhotoFromBatch = useCallback((batchId: string, photoId: string) => { + const batch = getBatch(batchId); + if (!batch) return; + batch.photos = batch.photos.filter((p) => p.id !== photoId); + if (batch.photos.length === 0) { + deleteBatch(batchId); + } else { + storage.set(`batch_${batchId}`, JSON.stringify(batch)); + } + }, [getBatch, deleteBatch]); + return { saveBatch, getBatch, @@ -55,5 +66,6 @@ export function useBatchStore() { addTagToBatch, removeTagFromBatch, deleteBatch, + removePhotoFromBatch, }; } diff --git a/mobile/hooks/usePdfGeneration.ts b/mobile/hooks/usePdfGeneration.ts index ef90d33..25694be 100644 --- a/mobile/hooks/usePdfGeneration.ts +++ b/mobile/hooks/usePdfGeneration.ts @@ -1,22 +1,21 @@ import { useState, useCallback } from 'react'; import * as Print from 'expo-print'; -import * as FileSystem from 'expo-file-system'; -import { CapturedPhoto } from '../types'; export function usePdfGeneration() { const [generating, setGenerating] = useState(false); + const [progress, setProgress] = useState(0); - const generatePdf = useCallback(async (photos: CapturedPhoto[]): Promise => { - if (photos.length === 0) return null; + const generatePdf = useCallback(async (items: { uri: string }[], progressCb?: (pct: number) => void): Promise => { + if (items.length === 0) return null; setGenerating(true); + setProgress(0); + progressCb?.(0); try { - const imagesHtml = photos.map((p) => { - const ext = p.filePath.split('.').pop()?.toLowerCase() || 'jpeg'; - const mime = ext === 'png' ? 'image/png' : 'image/jpeg'; + const imagesHtml = items.map((item) => { return `
- +
`; }).join(''); @@ -37,6 +36,8 @@ export function usePdfGeneration() { `; const { uri } = await Print.printToFileAsync({ html }); + setProgress(100); + progressCb?.(100); return uri; } catch (err) { console.error('PDF generation failed:', err); @@ -49,5 +50,6 @@ export function usePdfGeneration() { return { generatePdf, generating, + progress, }; }