regroup etc ...
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
+79
-1
@@ -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,7 +118,15 @@ 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}>
|
||||||
@@ -94,7 +141,13 @@ export function ScanScreen() {
|
|||||||
<View style={styles.captureInner} />
|
<View style={styles.captureInner} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{capturedCount > 0 ? (
|
||||||
|
<TouchableOpacity style={styles.finishButton} onPress={finishBatch}>
|
||||||
|
<Text style={styles.finishText}>✓</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
<View style={styles.torchButton} />
|
<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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+11
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user