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[];
+};