UNPKG

react-native-expo-cropper

Version:
609 lines (560 loc) 19.4 kB
import React, { useState, useRef, useEffect } from 'react'; import { StyleSheet, Text, View, TouchableOpacity, Alert, SafeAreaView, Platform, ActivityIndicator, useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Camera, CameraView } from 'expo-camera'; import { Ionicons } from '@expo/vector-icons'; // Max width for camera preview on large screens (tablets) so it doesn't stretch full width const CAMERA_PREVIEW_MAX_WIDTH = 500; export default function CustomCamera({ onPhotoCaptured, onCancel, instructionText = 'Place the tray inside the green box', }) { const { width: windowWidth } = useWindowDimensions(); const cameraPreviewWidth = Math.min(windowWidth, CAMERA_PREVIEW_MAX_WIDTH); const [isReady, setIsReady] = useState(false); const [loadingBeforeCapture, setLoadingBeforeCapture] = useState(false); const [hasPermission, setHasPermission] = useState(null); const [pictureSize, setPictureSize] = useState(undefined); const cameraRef = useRef(null); const cameraWrapperRef = useRef(null); const insets = useSafeAreaInsets(); const [cameraWrapperLayout, setCameraWrapperLayout] = useState({ width: 0, height: 0, x: 0, y: 0 }); const [greenFrame, setGreenFrame] = useState(null); const preferredNativeSizes = ['3072x4080', '3024x4032', '3000x4000', '2448x3264']; const parseSizeString = (size) => { const match = String(size || '').match(/(\d+)\s*[xX]\s*(\d+)/); if (!match) return null; const width = Number(match[1]); const height = Number(match[2]); const longEdge = Math.max(width, height); const shortEdge = Math.min(width, height); const ratio = longEdge / shortEdge; return { label: size, width, height, area: width * height, normalized: `${shortEdge}x${longEdge}`, isFourByThree: Math.abs(ratio - (4 / 3)) < 0.04, }; }; const chooseBestPictureSize = (sizes = []) => { if (!Array.isArray(sizes) || sizes.length === 0) return undefined; const parsedSizes = sizes .map(parseSizeString) .filter(Boolean); // 1) Force exact target sizes first (normalized to ignore orientation order) for (const preferred of preferredNativeSizes) { const preferredParsed = parseSizeString(preferred); if (!preferredParsed) continue; const exact = parsedSizes.find((item) => item.normalized === preferredParsed.normalized); if (exact) return exact.label; } // 2) Prefer high-resolution 4:3 sizes (phone still-photo native aspect) // Guard against tiny legacy 4:3 modes (e.g. 640x480). const MIN_HIGH_RES_AREA = 2_000_000; // ~2MP floor const highResFourByThree = parsedSizes .filter((item) => item.isFourByThree && item.area >= MIN_HIGH_RES_AREA) .sort((a, b) => b.area - a.area); if (highResFourByThree.length > 0) { return highResFourByThree[0].label; } // 3) Fallback to highest area available. parsedSizes.sort((a, b) => b.area - a.area); if (parsedSizes.length > 0) { return parsedSizes[0].label; } // iOS may return quality presets like "High", "Medium", "Low". const highPreset = sizes.find((size) => String(size).toLowerCase() === 'high'); return highPreset || sizes[0]; }; useEffect(() => { (async () => { const { status } = await Camera.requestCameraPermissionsAsync(); setHasPermission(status === 'granted'); })(); }, []); const requestPermissionAgain = async () => { try { const { status } = await Camera.requestCameraPermissionsAsync(); setHasPermission(status === 'granted'); } catch (e) { setHasPermission(false); } }; // Helper function to wait for multiple render cycles (works on iOS) const waitForRender = (cycles = 5) => { return new Promise((resolve) => { let count = 0; const tick = () => { count++; if (count >= cycles) { resolve(); } else { setImmediate(tick); } }; setImmediate(tick); }); }; // ✅ CRITICAL FIX: Calculate green frame coordinates relative to camera preview // The green frame should be calculated on the wrapper (as it's visually drawn there) // But we store it with wrapper dimensions so ImageCropper can map it correctly // // NOTE: Camera capture aspect ratio (typically 4:3) may differ from wrapper aspect ratio (9:16) // This is handled in ImageCropper by using "cover" mode to match preview content const calculateGreenFrameCoordinates = () => { const wrapperWidth = cameraWrapperLayout.width; const wrapperHeight = cameraWrapperLayout.height; if (wrapperWidth === 0 || wrapperHeight === 0) { console.warn("Camera wrapper layout not ready, cannot calculate green frame"); return null; } // ✅ Calculate green frame as percentage of WRAPPER const frameWidth = wrapperWidth * 0.85; // 85% of wrapper width const frameHeight = wrapperHeight * 0.70; // 70% of wrapper height const frameX = (wrapperWidth - frameWidth) / 2; // Centered horizontally const frameY = (wrapperHeight - frameHeight) / 2; // Centered vertically const frameCoords = { x: frameX, y: frameY, width: frameWidth, height: frameHeight, wrapperWidth, wrapperHeight, // ✅ Store percentages for easier mapping later percentX: (frameX / wrapperWidth) * 100, percentY: (frameY / wrapperHeight) * 100, percentWidth: 85, // 85% of wrapper width percentHeight: 70 // 70% of wrapper height }; console.log("✅ Green frame coordinates calculated:", frameCoords); return frameCoords; }; // 🔁 Keep green frame state in sync with wrapper layout so we can both render it // and send the exact same coordinates along with the captured photo. useEffect(() => { const coords = calculateGreenFrameCoordinates(); if (coords) { setGreenFrame(coords); } }, [cameraWrapperLayout]); const takePicture = async () => { if (!hasPermission) { Alert.alert( "Permission requise", "Veuillez autoriser l'accès à la caméra pour prendre une photo." ); return; } if (cameraRef.current) { try { // Show loading after a delay (using setImmediate for iOS compatibility) waitForRender(5).then(() => { setLoadingBeforeCapture(true); }); // Wait a bit before taking picture (works on iOS) await waitForRender(2); // ✅ OPTIMIZED: Capture with maximum quality and native camera ratio // Platform-specific optimizations for best quality const captureOptions = { // Maximum quality (0-1, 1 = best quality, no compression) quality: 1, // Disable shutter sound for better UX shutterSound: false, // ✅ CRITICAL: skipProcessing preserves original resolution and avoids interpolation // This ensures pixel-perfect quality and prevents premature resizing // NOTE: Some Android devices are unstable with skipProcessing; keep it off there. skipProcessing: Platform.OS === 'ios', // Include EXIF metadata (orientation, camera settings, etc.) exif: true, // ✅ Platform-specific optimizations ...(Platform.OS === 'ios' && { // iOS: Use native capture format (typically 4:3 or 16:9 depending on device) // No additional processing to preserve quality }), ...(Platform.OS === 'android' && { // Android: Ensure maximum resolution capture // skipProcessing already handles this, but we can add Android-specific options if needed }), }; console.log("📸 Capturing photo with maximum quality settings:", { platform: Platform.OS, options: captureOptions, wrapperSize: { width: cameraWrapperLayout.width, height: cameraWrapperLayout.height } }); const photo = await cameraRef.current.takePictureAsync(captureOptions); // ✅ Validate captured photo dimensions if (!photo.width || !photo.height || photo.width === 0 || photo.height === 0) { throw new Error("Invalid photo dimensions received from camera"); } const capturedAspectRatio = photo.width / photo.height; console.log("✅ Photo captured with maximum quality:", { uri: photo.uri, width: photo.width, height: photo.height, aspectRatio: capturedAspectRatio.toFixed(3), expectedRatio: "~1.33 (4:3) or ~1.78 (16:9)", exif: photo.exif ? "present" : "missing", fileSize: photo.uri ? "available" : "unknown" }); // ✅ CRITICAL FIX: Use the same green frame coordinates that are used for rendering // Fallback to recalculation if, for some reason, state is not yet set const greenFrameCoords = greenFrame || calculateGreenFrameCoordinates(); if (!greenFrameCoords) { throw new Error("Green frame coordinates not available"); } // ✅ Send photo URI and frame data to ImageCropper // The photo maintains its native resolution and aspect ratio // ImageCropper will handle display and cropping while preserving quality onPhotoCaptured(photo.uri, { greenFrame: greenFrameCoords, capturedImageSize: { width: photo.width, height: photo.height, aspectRatio: capturedAspectRatio } }); setLoadingBeforeCapture(false); } catch (error) { console.error("❌ Error capturing photo:", error); setLoadingBeforeCapture(false); Alert.alert( "Erreur", `Impossible de capturer la photo: ${error.message || "Erreur inconnue"}. Veuillez réessayer.` ); } } }; return ( <SafeAreaView style={styles.outerContainer}> {hasPermission === null && ( <View style={styles.permissionContainer}> <ActivityIndicator size="large" color={PRIMARY_GREEN} /> <Text style={styles.permissionText}>Demande d'autorisation caméra...</Text> </View> )} {hasPermission === false && ( <View style={styles.permissionContainer}> <Text style={styles.permissionText}> Autorisation caméra refusée. Activez-la dans les paramètres pour continuer. </Text> <TouchableOpacity style={styles.permissionButton} onPress={requestPermissionAgain}> <Text style={styles.permissionButtonText}>Réessayer</Text> </TouchableOpacity> </View> )} {hasPermission === true && ( <> <View style={[styles.cameraWrapper, { width: cameraPreviewWidth }]} ref={cameraWrapperRef} onLayout={(e) => { const layout = e.nativeEvent.layout; setCameraWrapperLayout({ width: layout.width, height: layout.height, x: layout.x, y: layout.y }); console.log("Camera wrapper layout updated:", layout); }} > <CameraView style={styles.camera} facing="back" ref={cameraRef} pictureSize={pictureSize} onCameraReady={async () => { try { setIsReady(false); const sizes = await cameraRef.current?.getAvailablePictureSizesAsync?.(); const bestSize = chooseBestPictureSize(sizes || []); if (bestSize) { setPictureSize(bestSize); console.log("✅ Selected highest available picture size:", { preferredNativeSizes, bestSize, availableSizes: sizes, }); } else { console.log("ℹ️ No explicit picture size selected; using camera default"); } } catch (sizeError) { console.warn("⚠️ Could not query picture sizes; using camera default:", sizeError); } finally { // Give CameraView a moment to apply pictureSize before enabling capture. await waitForRender(2); setIsReady(true); console.log("✅ Camera ready - Maximum quality capture enabled"); } }} // ✅ Ensure camera uses native aspect ratio (typically 4:3 for most devices) // The wrapper constrains the view, but capture uses full sensor resolution // This ensures preview matches what will be captured /> <View style={[styles.topBar, styles.topBarCentered]}> <Text style={[styles.topBarTitle, styles.topBarCenteredTitle]}>{instructionText}</Text> </View> {/* Loading overlay */} {loadingBeforeCapture && ( <> <View style={styles.loadingOverlay}> <ActivityIndicator size="large" color={PRIMARY_GREEN} /> </View> <View style={styles.touchBlocker} pointerEvents="auto" /> </> )} {/* Cadre de scan - rendered using calculated coordinates (x, y, width, height) */} {greenFrame && ( <View style={[ styles.scanFrame, { left: greenFrame.x, top: greenFrame.y, width: greenFrame.width, height: greenFrame.height, }, ]} /> )} </View> <View style={[styles.buttonContainer, { bottom: (insets?.bottom || 0) + 12, marginBottom: 0 }]}> <TouchableOpacity style={[styles.captureButton, (!isReady || loadingBeforeCapture || !hasPermission) && { opacity: 0.55 }]} onPress={takePicture} disabled={!isReady || loadingBeforeCapture || !hasPermission} > <Ionicons name="camera-outline" size={26} color="#121212" /> </TouchableOpacity> </View> </> )} </SafeAreaView> ); } const PRIMARY_GREEN = '#689F38'; const ACCENT_GREEN = '#689F38'; const DEEP_BLACK = '#060708'; const GLOW_WHITE = 'rgba(255, 255, 255, 0.9)'; const GLASS = 'rgba(20,22,26,0.9)'; const styles = StyleSheet.create({ outerContainer: { flex: 1, backgroundColor: DEEP_BLACK, justifyContent: 'center', alignItems: 'center', }, permissionContainer: { flex: 1, width: '100%', justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24, }, permissionText: { marginTop: 12, fontSize: 14, color: GLOW_WHITE, textAlign: 'center', }, permissionButton: { marginTop: 16, backgroundColor: GLASS, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', }, permissionButtonText: { color: 'white', fontWeight: '600', }, cameraWrapper: { aspectRatio: 9 / 16, borderRadius: 0, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', position: 'relative', }, cameraInstruction: { position: 'absolute', bottom: 170, left: 18, right: 18, textAlign: 'center', color: 'rgba(255,255,255,0.82)', fontSize: 13, fontWeight: '500', zIndex: 10, textShadowColor: 'rgba(0, 0, 0, 0.6)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2, pointerEvents: 'none', }, camera: { ...StyleSheet.absoluteFillObject, }, scanFrame: { position: 'absolute', borderWidth: 2, borderColor: ACCENT_GREEN, borderRadius: 2, backgroundColor: 'rgba(0, 0, 0, 0.1)', }, loadingOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: 20, justifyContent: 'center', alignItems: 'center', }, touchBlocker: { ...StyleSheet.absoluteFillObject, zIndex: 21, backgroundColor: 'transparent', }, buttonContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, marginBottom: 8, alignItems: 'center', justifyContent: 'center', }, captureButton: { marginTop: 10, width: 68, height: 68, borderRadius: 20, backgroundColor: ACCENT_GREEN, alignItems: 'center', justifyContent: 'center', shadowColor: ACCENT_GREEN, shadowOpacity: 0.42, shadowRadius: 10, shadowOffset: { width: 0, height: 2 }, elevation: 8, }, topBar: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 18, }, topBarCentered: { justifyContent: 'center', alignItems: 'center', paddingHorizontal: 0, }, topIconButton: { width: 36, height: 36, borderRadius: 12, backgroundColor: 'rgba(0,0,0,0.35)', alignItems: 'center', justifyContent: 'center', }, topBarTitle: { color: ACCENT_GREEN, fontSize: 30, fontWeight: '700', letterSpacing: 0.3, }, topBarCenteredTitle: { width: '100%', textAlign: 'center', paddingHorizontal: 18, }, detectBadge: { position: 'absolute', top: 76, left: 18, zIndex: 20, backgroundColor: 'rgba(40, 44, 50, 0.82)', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, }, detectBadgeText: { color: 'rgba(255,255,255,0.78)', fontSize: 12, fontWeight: '700', letterSpacing: 1.6, textTransform: 'uppercase', }, cornerHandle: { position: 'absolute', width: 24, height: 24, borderRadius: 12, backgroundColor: '#DFFFD0', borderWidth: 2, borderColor: ACCENT_GREEN, shadowColor: ACCENT_GREEN, shadowOpacity: 0.55, shadowRadius: 10, shadowOffset: { width: 0, height: 0 }, elevation: 8, zIndex: 25, }, dockLabel: { marginTop: 10, marginBottom: 10, color: 'rgba(255,255,255,0.63)', textAlign: 'center', fontSize: 12, letterSpacing: 3.2, textTransform: 'uppercase', }, dock: { height: 76, width: '92%', borderRadius: 22, backgroundColor: 'rgba(27,30,35,0.92)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', paddingHorizontal: 12, }, dockButton: { width: 44, height: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', }, dockButtonActive: { width: 74, height: 52, borderRadius: 16, backgroundColor: ACCENT_GREEN, shadowColor: ACCENT_GREEN, shadowOpacity: 0.4, shadowRadius: 10, shadowOffset: { width: 0, height: 1 }, elevation: 8, }, });