From db3d18cf33b9b41baafcfa5652f21c3ff10c6bfc Mon Sep 17 00:00:00 2001 From: m Date: Sun, 12 Jul 2026 15:41:17 +0200 Subject: [PATCH] regroup etc ... --- mobile/App.tsx | 2 + mobile/app/batch-review.tsx | 262 ++++++++++++++++++++++ mobile/app/scan.tsx | 82 ++++++- mobile/components/PhotoThumbnailStrip.tsx | 76 +++++++ mobile/hooks/useBatchStore.ts | 59 +++++ mobile/hooks/useCameraCapture.ts | 46 +++- mobile/hooks/usePdfGeneration.ts | 53 +++++ mobile/package-lock.json | 11 + mobile/package.json | 1 + mobile/types/index.ts | 16 ++ 10 files changed, 599 insertions(+), 9 deletions(-) create mode 100644 mobile/app/batch-review.tsx create mode 100644 mobile/components/PhotoThumbnailStrip.tsx create mode 100644 mobile/hooks/useBatchStore.ts create mode 100644 mobile/hooks/usePdfGeneration.ts diff --git a/mobile/App.tsx b/mobile/App.tsx index 1e3038f..b64b98b 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -7,6 +7,7 @@ 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'; const Stack = createNativeStackNavigator(); const queryClient = new QueryClient(); @@ -20,6 +21,7 @@ export default function App() { + diff --git a/mobile/app/batch-review.tsx b/mobile/app/batch-review.tsx new file mode 100644 index 0000000..605b76d --- /dev/null +++ b/mobile/app/batch-review.tsx @@ -0,0 +1,262 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, FlatList, TouchableOpacity, Image, Alert, ActivityIndicator, Share } from 'react-native'; +import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; +import { useBatchStore } from '../hooks/useBatchStore'; +import { usePdfGeneration } from '../hooks/usePdfGeneration'; + +type BatchReviewRouteParams = { + BatchReview: { batchId: string }; +}; + +export function BatchReviewScreen() { + const route = useRoute>(); + const navigation = useNavigation(); + const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore(); + const { generatePdf, generating } = usePdfGeneration(); + + const batch = getBatch(route.params.batchId); + + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [editingTags, setEditingTags] = useState(false); + const [tagInput, setTagInput] = useState(''); + + if (!batch) { + return ( + + Lot introuvable + + ); + } + + const toggleSelection = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + 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 ?', [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Partager', + onPress: () => { + Share.share({ url: uri, title: batch.name }); + }, + }, + ]); + } 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 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', + }); + }; + + return ( + + + {batch.name} + + {batch.photos.length} photo{batch.photos.length > 1 ? 's' : ''} • {formatDate(batch.createdAt)} + + + + item.id} + contentContainerStyle={styles.grid} + renderItem={({ item }) => { + const isSelected = selectedIds.has(item.id); + return ( + toggleSelection(item.id)} + > + + {isSelected && ( + + + + )} + + ); + }} + /> + + + + + {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' : ''}` : 'Toutes les photos'} + + + + {generating ? ( + + ) : ( + 📄 Générer 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', + }, + grid: { + padding: 4, + }, + gridItem: { + flex: 1 / 3, + aspectRatio: 1, + margin: 4, + borderRadius: 6, + overflow: 'hidden', + backgroundColor: '#f0f0f0', + }, + gridItemSelected: { + borderWidth: 3, + borderColor: '#1976D2', + }, + thumb: { + width: '100%', + height: '100%', + }, + selectedOverlay: { + ...StyleSheet.absoluteFill, + backgroundColor: 'rgba(25,118,210,0.3)', + justifyContent: 'center', + alignItems: 'center', + }, + selectedCheck: { + color: '#fff', + fontSize: 32, + fontWeight: '700', + }, + footer: { + padding: 16, + borderTopWidth: 1, + 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', + }, + actionButton: { + backgroundColor: '#1976D2', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + width: '100%', + alignItems: 'center', + }, + actionButtonDisabled: { + opacity: 0.6, + }, + actionText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/mobile/app/scan.tsx b/mobile/app/scan.tsx index 7a8a98a..94ee216 100644 --- a/mobile/app/scan.tsx +++ b/mobile/app/scan.tsx @@ -1,10 +1,25 @@ import React, { useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Camera } from 'react-native-vision-camera'; import { useCameraCapture } from '../hooks/useCameraCapture'; import { UploadProgress } from '../components/UploadProgress'; +import { PhotoThumbnailStrip } from '../components/PhotoThumbnailStrip'; +import { useBatchStore } from '../hooks/useBatchStore'; + +type RootStackParamList = { + Home: undefined; + Upload: undefined; + Scan: undefined; + Search: undefined; + BatchReview: { batchId: string }; +}; + +type NavigationProp = NativeStackNavigationProp; export function ScanScreen() { + const navigation = useNavigation(); const { cameraRef, hasPermission, @@ -19,8 +34,14 @@ export function ScanScreen() { toggleTorch, onStarted, onStopped, + capturedPhotos, + removeCapturedPhoto, + clearCapturedPhotos, + capturedCount, } = useCameraCapture(); + const { saveBatch } = useBatchStore(); + useEffect(() => { if (!hasPermission) { requestPermission(); @@ -31,11 +52,29 @@ export function ScanScreen() { idle: 'idle', capturing: 'uploading', uploading: 'uploading', - processing: 'processing', success: 'success', error: 'error', }; + const finishBatch = () => { + if (capturedPhotos.length === 0) return; + + const batchId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const now = new Date(); + const name = `Lot du ${now.toLocaleDateString('fr-FR')} ${now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; + + saveBatch({ + id: batchId, + name, + createdAt: now.toISOString(), + photos: capturedPhotos, + tags: [], + }); + + clearCapturedPhotos(); + navigation.navigate('BatchReview', { batchId }); + }; + if (!hasPermission) { return ( @@ -79,8 +118,16 @@ export function ScanScreen() { totalCount={1} uploadedCount={captureStatus === 'success' ? 1 : 0} /> + + {capturedCount > 0 && ( + + {capturedCount} photo{capturedCount > 1 ? 's' : ''} prise{capturedCount > 1 ? 's' : ''} + + )} + + {torchMode === 'on' ? '🔦' : '💡'} @@ -94,7 +141,13 @@ export function ScanScreen() { - + {capturedCount > 0 ? ( + + + + ) : ( + + )} ); @@ -153,6 +206,18 @@ const styles = StyleSheet.create({ left: 16, right: 16, }, + batchInfo: { + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: 8, + padding: 8, + marginTop: 8, + alignItems: 'center', + }, + batchInfoText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, bottomBar: { position: 'absolute', bottom: 50, @@ -192,4 +257,17 @@ const styles = StyleSheet.create({ borderRadius: 30, backgroundColor: '#fff', }, + finishButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: '#4CAF50', + justifyContent: 'center', + alignItems: 'center', + }, + finishText: { + color: '#fff', + fontSize: 22, + fontWeight: '700', + }, }); diff --git a/mobile/components/PhotoThumbnailStrip.tsx b/mobile/components/PhotoThumbnailStrip.tsx new file mode 100644 index 0000000..7061215 --- /dev/null +++ b/mobile/components/PhotoThumbnailStrip.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { View, Image, FlatList, StyleSheet, TouchableOpacity, Text } from 'react-native'; +import { CapturedPhoto } from '../types'; + +interface PhotoThumbnailStripProps { + photos: CapturedPhoto[]; + onRemove: (id: string) => void; +} + +export function PhotoThumbnailStrip({ photos, onRemove }: PhotoThumbnailStripProps) { + if (photos.length === 0) return null; + + return ( + + item.id} + renderItem={({ item }) => ( + + + onRemove(item.id)} + > + + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 120, + left: 0, + right: 0, + height: 80, + }, + list: { + paddingHorizontal: 16, + gap: 8, + }, + thumb: { + width: 64, + height: 64, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: '#333', + }, + image: { + width: '100%', + height: '100%', + }, + removeButton: { + position: 'absolute', + top: 2, + right: 2, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + alignItems: 'center', + }, + removeText: { + color: '#fff', + fontSize: 11, + fontWeight: '700', + }, +}); diff --git a/mobile/hooks/useBatchStore.ts b/mobile/hooks/useBatchStore.ts new file mode 100644 index 0000000..90218f8 --- /dev/null +++ b/mobile/hooks/useBatchStore.ts @@ -0,0 +1,59 @@ +import { useCallback } from 'react'; +import { createMMKV, useMMKVObject } from 'react-native-mmkv'; +import { Batch } from '../types'; + +const storage = createMMKV({ id: 'vaultdrop-batches' }); + +export function useBatchStore() { + const [batchIds, setBatchIds] = useMMKVObject('batch-ids', storage); + + const saveBatch = useCallback((batch: Batch) => { + storage.set(`batch_${batch.id}`, JSON.stringify(batch)); + const current = batchIds ?? []; + if (!current.includes(batch.id)) { + setBatchIds([batch.id, ...current]); + } + }, [batchIds, setBatchIds]); + + const getBatch = useCallback((id: string): Batch | undefined => { + const raw = storage.getString(`batch_${id}`); + if (!raw) return undefined; + return JSON.parse(raw) as Batch; + }, []); + + const getAllBatches = useCallback((): Batch[] => { + return (batchIds ?? []) + .map((id) => getBatch(id)) + .filter((b): b is Batch => b !== undefined); + }, [batchIds, getBatch]); + + const addTagToBatch = useCallback((batchId: string, tag: string) => { + const batch = getBatch(batchId); + if (!batch) return; + if (batch.tags.includes(tag)) return; + batch.tags = [...batch.tags, tag]; + storage.set(`batch_${batchId}`, JSON.stringify(batch)); + }, [getBatch]); + + const removeTagFromBatch = useCallback((batchId: string, tag: string) => { + const batch = getBatch(batchId); + if (!batch) return; + batch.tags = batch.tags.filter((t) => t !== tag); + storage.set(`batch_${batchId}`, JSON.stringify(batch)); + }, [getBatch]); + + const deleteBatch = useCallback((batchId: string) => { + storage.set(`batch_${batchId}`, undefined as any); + storage.remove(`batch_${batchId}` as any); + setBatchIds((batchIds ?? []).filter((id) => id !== batchId)); + }, [batchIds, setBatchIds]); + + return { + saveBatch, + getBatch, + getAllBatches, + addTagToBatch, + removeTagFromBatch, + deleteBatch, + }; +} diff --git a/mobile/hooks/useCameraCapture.ts b/mobile/hooks/useCameraCapture.ts index 8ab245a..808b482 100644 --- a/mobile/hooks/useCameraCapture.ts +++ b/mobile/hooks/useCameraCapture.ts @@ -1,8 +1,9 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera'; import { useUpload } from './useUpload'; +import { CapturedPhoto } from '../types'; -type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'processing' | 'success' | 'error'; +type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'success' | 'error'; export function useCameraCapture() { const { hasPermission, requestPermission } = useCameraPermission(); @@ -13,6 +14,7 @@ export function useCameraCapture() { const [captureError, setCaptureError] = useState(); const [torchMode, setTorchMode] = useState('off'); const [cameraReady, setCameraReady] = useState(false); + const [capturedPhotos, setCapturedPhotos] = useState([]); const upload = useUpload(); @@ -53,35 +55,61 @@ export function useCameraCapture() { setCameraReady(false); }, []); + const addCapturedPhoto = useCallback((photo: CapturedPhoto) => { + setCapturedPhotos((prev) => [...prev, photo]); + }, []); + + const removeCapturedPhoto = useCallback((id: string) => { + setCapturedPhotos((prev) => prev.filter((p) => p.id !== id)); + }, []); + + const clearCapturedPhotos = useCallback(() => { + setCapturedPhotos([]); + }, []); + const capturePhoto = useCallback(async () => { if (!photoOutput) return; + const photoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + try { setCaptureStatus('capturing'); setCaptureError(undefined); - const { filePath } = await photoOutput.capturePhotoToFile( - {}, - {} - ); + const { filePath } = await photoOutput.capturePhotoToFile({}, {}); setCaptureStatus('uploading'); + const capturedPhoto: CapturedPhoto = { + id: photoId, + filePath, + uri: 'file://' + filePath, + }; + + addCapturedPhoto(capturedPhoto); + const result = await upload.mutateAsync([ - { uri: 'file://' + filePath, type: 'image/jpeg', name: 'scan.jpg' }, + { uri: capturedPhoto.uri, type: 'image/jpeg', name: `scan_${photoId}.jpg` }, ]); if (result.errors.length > 0) { setCaptureStatus('error'); setCaptureError(result.errors[0].message); } else { + capturedPhoto.uploadedId = result.uploaded[0]?.id; + capturedPhoto.uploadedAt = new Date().toISOString(); setCaptureStatus('success'); } } catch (err) { setCaptureStatus('error'); setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture'); } - }, [photoOutput, upload]); + + setTimeout(() => { + setCaptureStatus('idle'); + setCaptureError(undefined); + }, 1500); + }, [photoOutput, upload, addCapturedPhoto]); return { cameraRef, @@ -97,5 +125,9 @@ export function useCameraCapture() { toggleTorch, onStarted, onStopped, + capturedPhotos, + removeCapturedPhoto, + clearCapturedPhotos, + capturedCount: capturedPhotos.length, }; } diff --git a/mobile/hooks/usePdfGeneration.ts b/mobile/hooks/usePdfGeneration.ts new file mode 100644 index 0000000..ef90d33 --- /dev/null +++ b/mobile/hooks/usePdfGeneration.ts @@ -0,0 +1,53 @@ +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 generatePdf = useCallback(async (photos: CapturedPhoto[]): Promise => { + if (photos.length === 0) return null; + + setGenerating(true); + + try { + const imagesHtml = photos.map((p) => { + const ext = p.filePath.split('.').pop()?.toLowerCase() || 'jpeg'; + const mime = ext === 'png' ? 'image/png' : 'image/jpeg'; + return `
+ +
`; + }).join(''); + + const html = ` + + + + + + +${imagesHtml} +`; + + const { uri } = await Print.printToFileAsync({ html }); + return uri; + } catch (err) { + console.error('PDF generation failed:', err); + return null; + } finally { + setGenerating(false); + } + }, []); + + return { + generatePdf, + generating, + }; +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index a9422e5..5a9ee9e 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -15,6 +15,7 @@ "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", + "expo-print": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", @@ -2887,6 +2888,16 @@ "react-native": "*" } }, + "node_modules/expo-print": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.0.tgz", + "integrity": "sha512-RgZCt1up7/jB1i9DpriiStjEYpJrQlw8rIICHfs/7WUNOA2sq3CEJu0knRHZwITo3/+L3c1Pfgh5S6i5eh0OMg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index b11081f..6f3bd5b 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -10,6 +10,7 @@ "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-picker": "~57.0.2", + "expo-print": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", diff --git a/mobile/types/index.ts b/mobile/types/index.ts index 4471a2b..c276831 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -60,3 +60,19 @@ export class UploadError extends HttpError { this.fileName = fileName; } } + +export type CapturedPhoto = { + id: string; + filePath: string; + uri: string; + uploadedId?: string; + uploadedAt?: string; +}; + +export type Batch = { + id: string; + name: string; + createdAt: string; + photos: CapturedPhoto[]; + tags: string[]; +};