regroup etc ...

This commit is contained in:
m
2026-07-12 15:41:17 +02:00
parent 64e7ae1679
commit db3d18cf33
10 changed files with 599 additions and 9 deletions
+2
View File
@@ -7,6 +7,7 @@ import { HomeScreen } from './app/index';
import { UploadScreen } from './app/upload'; import { UploadScreen } from './app/upload';
import { ScanScreen } from './app/scan'; import { ScanScreen } from './app/scan';
import { SearchScreen } from './app/search'; import { SearchScreen } from './app/search';
import { BatchReviewScreen } from './app/batch-review';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -20,6 +21,7 @@ export default function App() {
<Stack.Screen name="Upload" component={UploadScreen} options={{ title: 'Upload' }} /> <Stack.Screen name="Upload" component={UploadScreen} options={{ title: 'Upload' }} />
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} /> <Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} /> <Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
<Stack.Screen name="BatchReview" component={BatchReviewScreen} options={{ title: 'Revue du lot' }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
<StatusBar style="auto" /> <StatusBar style="auto" />
+262
View File
@@ -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<RouteProp<BatchReviewRouteParams, 'BatchReview'>>();
const navigation = useNavigation();
const { getBatch, addTagToBatch, removeTagFromBatch } = useBatchStore();
const { generatePdf, generating } = usePdfGeneration();
const batch = getBatch(route.params.batchId);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [editingTags, setEditingTags] = useState(false);
const [tagInput, setTagInput] = useState('');
if (!batch) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>Lot introuvable</Text>
</View>
);
}
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 (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{batch.name}</Text>
<Text style={styles.subtitle}>
{batch.photos.length} photo{batch.photos.length > 1 ? 's' : ''} {formatDate(batch.createdAt)}
</Text>
</View>
<FlatList
data={batch.photos}
numColumns={3}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.grid}
renderItem={({ item }) => {
const isSelected = selectedIds.has(item.id);
return (
<TouchableOpacity
style={[styles.gridItem, isSelected && styles.gridItemSelected]}
onPress={() => toggleSelection(item.id)}
>
<Image source={{ uri: item.uri }} style={styles.thumb} />
{isSelected && (
<View style={styles.selectedOverlay}>
<Text style={styles.selectedCheck}></Text>
</View>
)}
</TouchableOpacity>
);
}}
/>
<View style={styles.footer}>
<View style={styles.tagSection}>
<View style={styles.tagRow}>
{batch.tags.map((tag) => (
<TouchableOpacity
key={tag}
style={styles.tagChip}
onPress={() => removeTagFromBatch(batch.id, tag)}
>
<Text style={styles.tagText}>{tag} </Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.disabledNote}>Tags disponibles avec le backend (bientôt)</Text>
</View>
<View style={styles.actions}>
<Text style={styles.selectionInfo}>
{selectedCount > 0 ? `${selectedCount} sélectionnée${selectedCount > 1 ? 's' : ''}` : 'Toutes les photos'}
</Text>
<TouchableOpacity
style={[styles.actionButton, generating && styles.actionButtonDisabled]}
onPress={handleGeneratePdf}
disabled={generating}
>
{generating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.actionText}>📄 Générer PDF</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
);
}
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',
},
});
+80 -2
View File
@@ -1,10 +1,25 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; 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 { Camera } from 'react-native-vision-camera';
import { useCameraCapture } from '../hooks/useCameraCapture'; import { useCameraCapture } from '../hooks/useCameraCapture';
import { UploadProgress } from '../components/UploadProgress'; 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<RootStackParamList>;
export function ScanScreen() { export function ScanScreen() {
const navigation = useNavigation<NavigationProp>();
const { const {
cameraRef, cameraRef,
hasPermission, hasPermission,
@@ -19,8 +34,14 @@ export function ScanScreen() {
toggleTorch, toggleTorch,
onStarted, onStarted,
onStopped, onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount,
} = useCameraCapture(); } = useCameraCapture();
const { saveBatch } = useBatchStore();
useEffect(() => { useEffect(() => {
if (!hasPermission) { if (!hasPermission) {
requestPermission(); requestPermission();
@@ -31,11 +52,29 @@ export function ScanScreen() {
idle: 'idle', idle: 'idle',
capturing: 'uploading', capturing: 'uploading',
uploading: 'uploading', uploading: 'uploading',
processing: 'processing',
success: 'success', success: 'success',
error: 'error', 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) { if (!hasPermission) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
@@ -79,8 +118,16 @@ export function ScanScreen() {
totalCount={1} totalCount={1}
uploadedCount={captureStatus === 'success' ? 1 : 0} uploadedCount={captureStatus === 'success' ? 1 : 0}
/> />
{capturedCount > 0 && (
<View style={styles.batchInfo}>
<Text style={styles.batchInfoText}>{capturedCount} photo{capturedCount > 1 ? 's' : ''} prise{capturedCount > 1 ? 's' : ''}</Text>
</View>
)}
</View> </View>
<PhotoThumbnailStrip photos={capturedPhotos} onRemove={removeCapturedPhoto} />
<View style={styles.bottomBar}> <View style={styles.bottomBar}>
<TouchableOpacity style={styles.torchButton} onPress={toggleTorch}> <TouchableOpacity style={styles.torchButton} onPress={toggleTorch}>
<Text style={styles.torchIcon}>{torchMode === 'on' ? '🔦' : '💡'}</Text> <Text style={styles.torchIcon}>{torchMode === 'on' ? '🔦' : '💡'}</Text>
@@ -94,7 +141,13 @@ export function ScanScreen() {
<View style={styles.captureInner} /> <View style={styles.captureInner} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.torchButton} /> {capturedCount > 0 ? (
<TouchableOpacity style={styles.finishButton} onPress={finishBatch}>
<Text style={styles.finishText}>✓</Text>
</TouchableOpacity>
) : (
<View style={styles.torchButton} />
)}
</View> </View>
</View> </View>
); );
@@ -153,6 +206,18 @@ const styles = StyleSheet.create({
left: 16, left: 16,
right: 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: { bottomBar: {
position: 'absolute', position: 'absolute',
bottom: 50, bottom: 50,
@@ -192,4 +257,17 @@ const styles = StyleSheet.create({
borderRadius: 30, borderRadius: 30,
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
finishButton: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#4CAF50',
justifyContent: 'center',
alignItems: 'center',
},
finishText: {
color: '#fff',
fontSize: 22,
fontWeight: '700',
},
}); });
+76
View File
@@ -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 (
<View style={styles.container}>
<FlatList
data={photos}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.thumb}>
<Image source={{ uri: item.uri }} style={styles.image} />
<TouchableOpacity
style={styles.removeButton}
onPress={() => onRemove(item.id)}
>
<Text style={styles.removeText}></Text>
</TouchableOpacity>
</View>
)}
/>
</View>
);
}
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',
},
});
+59
View File
@@ -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<string[]>('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,
};
}
+39 -7
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera'; import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera';
import { useUpload } from './useUpload'; 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() { export function useCameraCapture() {
const { hasPermission, requestPermission } = useCameraPermission(); const { hasPermission, requestPermission } = useCameraPermission();
@@ -13,6 +14,7 @@ export function useCameraCapture() {
const [captureError, setCaptureError] = useState<string>(); const [captureError, setCaptureError] = useState<string>();
const [torchMode, setTorchMode] = useState<TorchMode>('off'); const [torchMode, setTorchMode] = useState<TorchMode>('off');
const [cameraReady, setCameraReady] = useState(false); const [cameraReady, setCameraReady] = useState(false);
const [capturedPhotos, setCapturedPhotos] = useState<CapturedPhoto[]>([]);
const upload = useUpload(); const upload = useUpload();
@@ -53,35 +55,61 @@ export function useCameraCapture() {
setCameraReady(false); 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 () => { const capturePhoto = useCallback(async () => {
if (!photoOutput) return; if (!photoOutput) return;
const photoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
try { try {
setCaptureStatus('capturing'); setCaptureStatus('capturing');
setCaptureError(undefined); setCaptureError(undefined);
const { filePath } = await photoOutput.capturePhotoToFile( const { filePath } = await photoOutput.capturePhotoToFile({}, {});
{},
{}
);
setCaptureStatus('uploading'); setCaptureStatus('uploading');
const capturedPhoto: CapturedPhoto = {
id: photoId,
filePath,
uri: 'file://' + filePath,
};
addCapturedPhoto(capturedPhoto);
const result = await upload.mutateAsync([ 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) { if (result.errors.length > 0) {
setCaptureStatus('error'); setCaptureStatus('error');
setCaptureError(result.errors[0].message); setCaptureError(result.errors[0].message);
} else { } else {
capturedPhoto.uploadedId = result.uploaded[0]?.id;
capturedPhoto.uploadedAt = new Date().toISOString();
setCaptureStatus('success'); setCaptureStatus('success');
} }
} catch (err) { } catch (err) {
setCaptureStatus('error'); setCaptureStatus('error');
setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture'); setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture');
} }
}, [photoOutput, upload]);
setTimeout(() => {
setCaptureStatus('idle');
setCaptureError(undefined);
}, 1500);
}, [photoOutput, upload, addCapturedPhoto]);
return { return {
cameraRef, cameraRef,
@@ -97,5 +125,9 @@ export function useCameraCapture() {
toggleTorch, toggleTorch,
onStarted, onStarted,
onStopped, onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount: capturedPhotos.length,
}; };
} }
+53
View File
@@ -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<string | null> => {
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 `<div style="page-break-after: always; display: flex; justify-content: center; align-items: center; height: 100vh;">
<img src="${p.uri}" style="max-width: 100%; max-height: 100vh; object-fit: contain;" />
</div>`;
}).join('');
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #fff; }
@media print {
@page { margin: 0; }
}
</style>
</head>
<body>${imagesHtml}</body>
</html>`;
const { uri } = await Print.printToFileAsync({ html });
return uri;
} catch (err) {
console.error('PDF generation failed:', err);
return null;
} finally {
setGenerating(false);
}
}, []);
return {
generatePdf,
generating,
};
}
+11
View File
@@ -15,6 +15,7 @@
"expo-document-picker": "~57.0.0", "expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0", "expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2", "expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
@@ -2887,6 +2888,16 @@
"react-native": "*" "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": { "node_modules/expo-server": {
"version": "57.0.0", "version": "57.0.0",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
"expo-document-picker": "~57.0.0", "expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0", "expo-file-system": "~57.0.0",
"expo-image-picker": "~57.0.2", "expo-image-picker": "~57.0.2",
"expo-print": "~57.0.0",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
+16
View File
@@ -60,3 +60,19 @@ export class UploadError extends HttpError {
this.fileName = fileName; 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[];
};