diff --git a/mobile/app.json b/mobile/app.json index c7d420f..40b1782 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -11,17 +11,23 @@ "infoPlist": { "NSCameraUsageDescription": "VaultDrop a besoin d'accéder à votre caméra pour scanner des documents.", "NSPhotoLibraryUsageDescription": "VaultDrop a besoin d'accéder à votre galerie pour sélectionner des photos." - } + }, + "bundleIdentifier": "com.anonymous.webui" }, "android": { - "permissions": ["CAMERA", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"], + "permissions": [ + "CAMERA", + "READ_EXTERNAL_STORAGE", + "WRITE_EXTERNAL_STORAGE" + ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/android-icon-foreground.png", "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" }, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "com.anonymous.webui" }, "web": { "favicon": "./assets/favicon.png" diff --git a/mobile/app/scan.tsx b/mobile/app/scan.tsx index c365a65..7a8a98a 100644 --- a/mobile/app/scan.tsx +++ b/mobile/app/scan.tsx @@ -1,42 +1,101 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; -import * as ImagePicker from 'expo-image-picker'; +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Camera } from 'react-native-vision-camera'; +import { useCameraCapture } from '../hooks/useCameraCapture'; +import { UploadProgress } from '../components/UploadProgress'; export function ScanScreen() { - const takePhoto = async () => { - try { - const { status } = await ImagePicker.requestCameraPermissionsAsync(); - if (status !== 'granted') { - Alert.alert('Permission requise', "L'accès à la caméra est nécessaire pour scanner un document."); - return; - } + const { + cameraRef, + hasPermission, + requestPermission, + device, + photoOutput, + capturePhoto, + captureStatus, + captureError, + isActive, + torchMode, + toggleTorch, + onStarted, + onStopped, + } = useCameraCapture(); - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ['images'], - quality: 1, - }); - - if (result.canceled) return; - - if (result.assets && result.assets[0]) { - console.log('Photo taken:', result.assets[0]); - // TODO: Send to backend for OCR - } - } catch (err) { - Alert.alert('Erreur', "Impossible d'accéder à la caméra"); + useEffect(() => { + if (!hasPermission) { + requestPermission(); } + }, [hasPermission, requestPermission]); + + const statusToUploadProgress: Record = { + idle: 'idle', + capturing: 'uploading', + uploading: 'uploading', + processing: 'processing', + success: 'success', + error: 'error', }; + if (!hasPermission) { + return ( + + Permission caméra requise + + Accorder l'accès + + + ); + } + + if (!device) { + return ( + + + Caméra initialisation... + + ); + } + return ( - Scanner un document - - Prenez une photo de votre document pour extraire le texte via OCR - + - - Prendre une photo - + + + + + + + + + + + {torchMode === 'on' ? '🔦' : '💡'} + + + + + + + + ); } @@ -44,32 +103,93 @@ export function ScanScreen() { const styles = StyleSheet.create({ container: { flex: 1, - padding: 16, - backgroundColor: '#fff', + backgroundColor: '#000', + }, + center: { + flex: 1, justifyContent: 'center', alignItems: 'center', + backgroundColor: '#000', + padding: 24, }, - title: { - fontSize: 24, - fontWeight: '700', - marginBottom: 12, - }, - description: { - fontSize: 16, - color: '#666', + permissionText: { + fontSize: 18, + color: '#fff', textAlign: 'center', - marginBottom: 32, + marginBottom: 24, }, - scanButton: { - backgroundColor: '#4CAF50', - padding: 16, + permissionButton: { + backgroundColor: '#1976D2', + paddingHorizontal: 32, + paddingVertical: 14, borderRadius: 8, - width: '100%', - alignItems: 'center', }, - scanText: { + permissionButtonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, + loadingText: { + fontSize: 16, + color: '#fff', + marginTop: 16, + }, + viewfinderOverlay: { + ...StyleSheet.absoluteFill, + justifyContent: 'center', + alignItems: 'center', + }, + viewfinderFrame: { + width: '85%', + maxWidth: 400, + aspectRatio: 3 / 4, + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.6)', + borderRadius: 12, + }, + topOverlay: { + position: 'absolute', + top: 60, + left: 16, + right: 16, + }, + bottomBar: { + position: 'absolute', + bottom: 50, + left: 0, + right: 0, + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + paddingHorizontal: 32, + }, + torchButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + torchIcon: { + fontSize: 24, + }, + captureButton: { + width: 76, + height: 76, + borderRadius: 38, + borderWidth: 4, + borderColor: '#fff', + justifyContent: 'center', + alignItems: 'center', + }, + captureButtonDisabled: { + opacity: 0.5, + }, + captureInner: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: '#fff', + }, }); diff --git a/mobile/hooks/useCameraCapture.ts b/mobile/hooks/useCameraCapture.ts new file mode 100644 index 0000000..8ab245a --- /dev/null +++ b/mobile/hooks/useCameraCapture.ts @@ -0,0 +1,101 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera'; +import { useUpload } from './useUpload'; + +type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'processing' | 'success' | 'error'; + +export function useCameraCapture() { + const { hasPermission, requestPermission } = useCameraPermission(); + const device = useCameraDevice('back'); + const cameraRef = useRef(null); + + const [captureStatus, setCaptureStatus] = useState('idle'); + const [captureError, setCaptureError] = useState(); + const [torchMode, setTorchMode] = useState('off'); + const [cameraReady, setCameraReady] = useState(false); + + const upload = useUpload(); + + const photoOutput = usePhotoOutput(); + + useEffect(() => { + if (!hasPermission) { + requestPermission(); + } + }, [hasPermission, requestPermission]); + + const isActive = hasPermission && !!device && captureStatus !== 'uploading'; + + const toggleTorch = useCallback(async () => { + const next = torchMode === 'off' ? 'on' : 'off'; + setTorchMode(next); + if (cameraReady && cameraRef.current) { + try { + const controller = cameraRef.current.controller; + if (controller?.device.hasTorch) { + await controller.setTorchMode(next); + } + } catch { + setTorchMode(torchMode); + } + } + }, [torchMode, cameraReady]); + + const onStarted = useCallback(() => { + setCameraReady(true); + if (torchMode === 'on' && cameraRef.current) { + const controller = cameraRef.current.controller; + controller?.setTorchMode('on').catch(() => {}); + } + }, [torchMode]); + + const onStopped = useCallback(() => { + setCameraReady(false); + }, []); + + const capturePhoto = useCallback(async () => { + if (!photoOutput) return; + + try { + setCaptureStatus('capturing'); + setCaptureError(undefined); + + const { filePath } = await photoOutput.capturePhotoToFile( + {}, + {} + ); + + setCaptureStatus('uploading'); + + const result = await upload.mutateAsync([ + { uri: 'file://' + filePath, type: 'image/jpeg', name: 'scan.jpg' }, + ]); + + if (result.errors.length > 0) { + setCaptureStatus('error'); + setCaptureError(result.errors[0].message); + } else { + setCaptureStatus('success'); + } + } catch (err) { + setCaptureStatus('error'); + setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture'); + } + }, [photoOutput, upload]); + + return { + cameraRef, + hasPermission, + requestPermission, + device, + photoOutput, + capturePhoto, + captureStatus, + captureError, + isActive, + torchMode, + toggleTorch, + onStarted, + onStopped, + }; +} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 2474a68..a9422e5 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -19,8 +19,11 @@ "react": "19.2.3", "react-native": "0.86.0", "react-native-mmkv": "^4.3.2", + "react-native-nitro-image": "^0.15.1", + "react-native-nitro-modules": "^0.36.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2" + "react-native-screens": "4.25.2", + "react-native-vision-camera": "^5.1.0" }, "devDependencies": { "@types/react": "~19.2.2", @@ -5268,12 +5271,25 @@ "react-native-nitro-modules": "*" } }, + "node_modules/react-native-nitro-image": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-image/-/react-native-nitro-image-0.15.1.tgz", + "integrity": "sha512-slrImfUgasAdzylTiNWuTkawE0R4t7LGh+7N2VFa/D9+SxlQbkKYp1J6hA3XkkEnTnUCKaea39ZVg+1xx665MQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + } + }, "node_modules/react-native-nitro-modules": { "version": "0.36.1", "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.1.tgz", "integrity": "sha512-kBv/VvKqAmkXAvP1DxJMC9b/fRhh7JdSO4EUnPP46hJjrIFeFR8AwKm8mYaKZEuF014M/TVdv2vomVUW0umsQQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -5303,6 +5319,18 @@ "react-native": ">=0.82.0" } }, + "node_modules/react-native-vision-camera": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.1.0.tgz", + "integrity": "sha512-1rsZ4caFLmOhQ9wediPuqd+WEdRzLZ2/tMfo4Itdeuyx4V/sPkerIgYT2DYYvyXbB8swvFgEW7ihIdnUkarqeA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-image": "*", + "react-native-nitro-modules": "*" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index f5da8ca..b11081f 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,8 +14,11 @@ "react": "19.2.3", "react-native": "0.86.0", "react-native-mmkv": "^4.3.2", + "react-native-nitro-image": "^0.15.1", + "react-native-nitro-modules": "^0.36.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2" + "react-native-screens": "4.25.2", + "react-native-vision-camera": "^5.1.0" }, "devDependencies": { "@types/react": "~19.2.2", @@ -23,8 +26,8 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "private": true