import React, { useRef, useState, useEffect, useCallback } from "react"; import { View, Text, StyleSheet, Dimensions, TouchableOpacity, Alert, Vibration, Animated, Platform, ScrollView, } from "react-native"; import { CameraView, CameraType, useCameraPermissions } from "expo-camera"; import * as MediaLibrary from "expo-media-library"; import Svg, { Circle, Rect, Line, Text as SvgText, Path, } from "react-native-svg"; import { PitchDetector, AngleMetrics, calculateAngleColor, getAngleMessage, getAngleMessageTranslated, } from "../utils/pitchDetector"; import { YawDetector, YawMetrics, getYawColor, getYawMessage, getYawMessageTranslated, } from "../utils/yawDetector"; import { RealtimeBrightnessDetector, LightingMetrics, } from "../utils/realtimeBrightnessDetectorV2"; import { MotionDetector, MotionMetrics, getMotionStabilityMessage, } from "../utils/motionDetectorV2"; import { FallbackSpeedDetector as SpeedDetector, SpeedMetrics, getSpeedColor, getSpeedMessage, getSpeedRecommendationMessage, shouldAllowRecordingSpeed, getSpeedIcon, } from "../utils/fallbackSpeedDetector"; import { VideoData, SupportedLanguage, InstructionEvent, GuidedCameraViewProps, } from "../types"; import { getTranslations } from "../utils/translations"; // SVG Icon Component const CubeIcon = ({ size = 22, color = "#FFFFFF", }: { size?: number; color?: string; }) => ( ); const SwitchIcon = ({ size = 22, color = "#FFFFFF", }: { size?: number; color?: string; }) => ( ); const XCloseIcon = ({ size = 22, color = "#FFFFFF", }: { size?: number; color?: string; }) => ( ); const SaveIcon = ({ size = 22, color = "#FFFFFF", }: { size?: number; color?: string; }) => ( ); const DeleteIcon = ({ size = 22, color = "#FFFFFF", }: { size?: number; color?: string; }) => ( ); // Helper functions for motion detection const getMotionColor = (stability: string): string => { switch (stability) { case "excellent": return "#4CAF50"; case "good": return "#8BC34A"; case "fair": return "#FFC107"; case "poor": return "#FF9800"; case "very_poor": return "#F44336"; default: return "#FFC107"; } }; const getMotionMessage = (metrics: MotionMetrics): string => { return metrics.recommendation; }; const getLightingColor = (quality: string): string => { switch (quality) { case "excellent": return "#4CAF50"; case "good": return "#8BC34A"; case "fair": return "#FFC107"; case "poor": return "#FF9800"; case "very_poor": return "#F44336"; default: return "#FFC107"; } }; const shouldAllowRecording = (metrics: MotionMetrics): boolean => { return metrics.isStable; }; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); // Helper function to generate guidance messages based on current vs target angle const generateGuidanceMessage = ( current: AngleMetrics, yawMetrics: YawMetrics, target: { roll: number; pitch: number; yaw?: number }, translations: ReturnType ): string => { const rollDiff = current.roll - target.roll; const pitchDiff = current.pitch - target.pitch; const tolerance = 5; // degrees // Check if we're close enough to target if ( Math.abs(rollDiff) <= tolerance && Math.abs(pitchDiff) <= tolerance && yawMetrics.isOnTarget ) { return translations.perfectHoldSteady; } // Generate directional guidance const messages: string[] = []; // Yaw guidance (compass direction) - highest priority if (!yawMetrics.isOnTarget && target.yaw !== undefined) { messages.push(getYawMessageTranslated(yawMetrics, translations)); } // Roll guidance (left/right rotation) if (Math.abs(rollDiff) > tolerance) { if (rollDiff > 0) { messages.push(translations.rotateLeft); } else { messages.push(translations.rotateRight); } } // Pitch guidance (up/down tilt) if (Math.abs(pitchDiff) > tolerance) { if (pitchDiff > 0) { messages.push(translations.tiltDown); } else { messages.push(translations.tiltUp); } } return messages.join(" • "); }; // Helper function to check if current angle matches target const isAngleOnTarget = ( current: AngleMetrics, yawMetrics: YawMetrics, target: { roll: number; pitch: number; yaw?: number }, tolerance: number = 5 ): boolean => { const rollDiff = Math.abs(current.roll - target.roll); const pitchDiff = Math.abs(current.pitch - target.pitch); return ( rollDiff <= tolerance && pitchDiff <= tolerance && (target.yaw === undefined || yawMetrics.isOnTarget) ); }; const GuidedCameraView: React.FC = ({ onCameraClose, onScreen = false, terminalLogs = false, onVideoSave, language = "english", metricsUpdateInterval = 100, // Default 100ms update interval includeSeverityLevels = ["info", "warning", "error"], // Default: include all severity levels // Metrics activation props with defaults enableAngleMetrics = true, enableMotionMetrics = true, enableSpeedMetrics = true, enableLightingMetrics = true, enableYawMetrics = true, enableGuidanceMode = true, // Configuration props for each detector pitchDetectorConfig = {}, yawDetectorConfig = {}, motionDetectorConfig = {}, speedDetectorConfig = {}, brightnessDetectorConfig = {}, }) => { const translations = getTranslations(language); const isRTL = language === "arabic"; // Helper function to get text style with appropriate font const getTextStyle = (weight: "regular" | "bold" = "regular") => { return { fontWeight: (weight === "bold" ? "bold" : "normal") as "normal" | "bold", ...(isRTL && { textAlign: "right" as const, writingDirection: "rtl" as const, }), }; }; // Helper function to get translated quality text const getQualityTranslation = (quality: string): string => { const qualityKey = quality.replace("_", "") as keyof typeof translations; return ( (translations as any)[qualityKey] || (translations as any)[quality] || quality ); }; const [facing, setFacing] = useState("back"); const [permission, requestPermission] = useCameraPermissions(); const [hasMediaLibraryPermission, setHasMediaLibraryPermission] = useState(false); // Camera mode state for dynamic switching const [cameraMode, setCameraMode] = useState<"picture" | "video">("picture"); // Recording state - exactly like VideoRecorderApp const [isRecording, setIsRecording] = useState(false); const [recordedVideo, setRecordedVideo] = useState(null); const [recordingDuration, setRecordingDuration] = useState(0); const [instructionEvents, setInstructionEvents] = useState< InstructionEvent[] >([]); const [lastInstructionTime, setLastInstructionTime] = useState< Record >({}); const [motionMetrics, setMotionMetrics] = useState({ score: 100, isStable: true, stability: "excellent", accelerationMagnitude: 0, rotationMagnitude: 0, recommendation: "Perfect stability!", source: "gyroscope", }); const [speedMetrics, setSpeedMetrics] = useState({ speed: 0, speedKmh: 0, speedMph: 0, accuracy: 0, isMoving: false, movementType: "stationary", recommendation: "Device is stationary - perfect for stable recording", source: "gps", }); const [lightingMetrics, setLightingMetrics] = useState({ meanLuminance: 128, contrastRatio: 3.0, shadowDetail: 20, highlightClipping: 0, colorTemperature: 5500, quality: "good", isOptimal: true, recommendation: "Lighting looks good", score: 85, source: "estimated", }); const [pulseAnim] = useState(new Animated.Value(1)); const [angleMetrics, setAngleMetrics] = useState({ roll: 0, pitch: 0, isLevel: true, direction: "level", severity: "good", }); const [isCameraReady, setIsCameraReady] = useState(false); const [isCameraInitialized, setIsCameraInitialized] = useState(false); // Target angle state - the desired orientation we want to guide users to const [targetAngle, setTargetAngle] = useState({ roll: 0, pitch: 0, yaw: undefined as number | undefined, }); const [isGuidanceMode, setIsGuidanceMode] = useState(false); const [guidanceMessage, setGuidanceMessage] = useState(""); // Metrics logging state const [metricsLogs, setMetricsLogs] = useState([]); const maxLogs = 50; // Keep only the last 50 log entries // Helper function to add log entry const addLog = (message: string) => { const timestamp = new Date().toLocaleTimeString(); const logEntry = `[${timestamp}] ${message}`; // Terminal logs if (terminalLogs) { console.log(`📊 ${logEntry}`); } // On-screen logs if (onScreen) { setMetricsLogs((prev) => { const newLogs = [logEntry, ...prev]; return newLogs.slice(0, maxLogs); // Keep only the latest entries }); } }; // Helper function to record instruction events during recording const recordInstructionEvent = ( category: InstructionEvent["category"], severity: InstructionEvent["severity"], message: string, throttleMs: number = 2000 // Don't record same category more than once every 2 seconds ) => { if (!isRecording || recordingStartTime.current === 0) { return; // Only record during active recording } // Check if this severity level should be included if (!includeSeverityLevels.includes(severity)) { return; // Skip if severity not included in filter } // Throttling: don't record the same category too frequently const now = Date.now(); const lastTime = lastInstructionTime[category] || 0; if (now - lastTime < throttleMs) { return; } const elapsedMs = Date.now() - recordingStartTime.current; const minutes = Math.floor(elapsedMs / 60000); const seconds = Math.floor((elapsedMs % 60000) / 1000); const timestamp = `${minutes.toString().padStart(2, "0")}:${seconds .toString() .padStart(2, "0")}`; const instructionEvent: InstructionEvent = { timestamp, timestampMs: elapsedMs, category, severity, message, metrics: { pitch: angleMetrics.pitch, roll: angleMetrics.roll, yaw: yawMetrics.yaw, motionScore: motionMetrics.score, speedKmh: speedMetrics.speedKmh, brightness: lightingMetrics.meanLuminance, }, }; setInstructionEvents((prev) => [...prev, instructionEvent]); setLastInstructionTime((prev) => ({ ...prev, [category]: now })); }; // Yaw tracking state const [yawMetrics, setYawMetrics] = useState({ yaw: 0, isOnTarget: true, deviation: 0, direction: "on_target", severity: "good", }); // Refs const cameraRef = useRef(null); const pitchDetectorRef = useRef(null); const recordingStartTime = useRef(0); const durationInterval = useRef(null); const lastVibrationRef = useRef(0); const motionDetectorRef = useRef(null); const yawDetectorRef = useRef(null); const speedDetectorRef = useRef(null); // Request permissions on mount useEffect(() => { (async () => { const mediaLibraryStatus = await MediaLibrary.requestPermissionsAsync(); setHasMediaLibraryPermission(mediaLibraryStatus.status === "granted"); })(); }, []); // YawDetector effect for compass tracking useEffect(() => { if (!enableYawMetrics) return; const handleYawChange = (metrics: YawMetrics) => { setYawMetrics(metrics); addLog( `Yaw: ${metrics.direction} (${Math.round( metrics.yaw )}°, Dev: ${metrics.deviation.toFixed(1)}°)` ); }; // Merge global update interval with yaw detector specific config const yawConfig = { updateInterval: metricsUpdateInterval, yawTolerance: 5, smoothingFactor: 0.8, ...yawDetectorConfig, // Override with specific config }; yawDetectorRef.current = new YawDetector(handleYawChange, yawConfig); yawDetectorRef.current.start(); return () => { if (yawDetectorRef.current) { yawDetectorRef.current.stop(); } }; }, [metricsUpdateInterval, enableYawMetrics, yawDetectorConfig]); // Update guidance message when yaw metrics change useEffect(() => { if (isGuidanceMode && enableGuidanceMode) { const guidance = generateGuidanceMessage( angleMetrics, yawMetrics, targetAngle, translations ); setGuidanceMessage(guidance); } }, [ yawMetrics, isGuidanceMode, angleMetrics, targetAngle, translations, enableGuidanceMode, ]); useEffect(() => { if (!enableMotionMetrics) return; const handleMotionChange = (metrics: MotionMetrics) => { setMotionMetrics(metrics); addLog( `Motion: ${metrics.stability} (Score: ${metrics.score}, ${metrics.source})` ); // Optional: Vibration feedback for very poor stability const now = Date.now(); if ( metrics.stability === "very_poor" && now - lastVibrationRef.current > 3000 ) { Vibration.vibrate([200, 100, 200]); lastVibrationRef.current = now; } }; // Merge global update interval with motion detector specific config const motionConfig = { updateInterval: metricsUpdateInterval, historySize: 8, excellentThreshold: 75, // Lowered from 85 - easier to get "excellent" goodThreshold: 60, // Lowered from 70 - easier to get "good" fairThreshold: 40, // Lowered from 50 - easier to get "fair" poorThreshold: 20, // Lowered from 30 - easier to get "poor" accelerationWeight: 0.6, rotationWeight: 0.4, smoothingFactor: 0.7, ...motionDetectorConfig, // Override with specific config }; motionDetectorRef.current = new MotionDetector( handleMotionChange, motionConfig ); motionDetectorRef.current.start(); return () => { if (motionDetectorRef.current) { motionDetectorRef.current.stop(); } }; }, [metricsUpdateInterval, enableMotionMetrics, motionDetectorConfig]); // SpeedDetector effect for movement tracking useEffect(() => { if (!enableSpeedMetrics) return; const handleSpeedChange = (metrics: SpeedMetrics) => { setSpeedMetrics(metrics); addLog( `Speed: ${metrics.movementType} (${metrics.speedKmh.toFixed(1)} km/h, ${ metrics.source })` ); }; // Use only the accelerometer-based speed detector (no GPS required) const initSpeedDetector = async () => { try { // Merge global update interval with speed detector specific config const speedConfig = { updateInterval: Math.max(metricsUpdateInterval, 1000), // Speed detection minimum 1 second enableSensorFusion: true, movingThreshold: 0.3, smoothingFactor: 0.8, ...speedDetectorConfig, // Override with specific config }; speedDetectorRef.current = new SpeedDetector( handleSpeedChange, speedConfig ); await speedDetectorRef.current.start(); } catch (error) { console.error("Failed to start speed detector:", error); } }; initSpeedDetector(); return () => { if (speedDetectorRef.current) { speedDetectorRef.current.stop(); } }; }, [metricsUpdateInterval, enableSpeedMetrics, speedDetectorConfig]); const brightnessDetectorRef = useRef(null); useEffect(() => { if (!enableLightingMetrics) return; const handleLightingChange = (metrics: LightingMetrics) => { setLightingMetrics(metrics); addLog( `Lighting: ${metrics.quality} (Luminance: ${Math.round( metrics.meanLuminance )}, Score: ${metrics.score})` ); }; // Merge global update interval with brightness detector specific config const brightnessConfig = { updateInterval: Math.max(metricsUpdateInterval * 4, 1000), // Brightness detection at least 1 second, typically 4x slower than other metrics enableTimeBasedEstimation: true, enableAmbientLightSensor: true, // Use ambient light sensor for better accuracy smoothingFactor: 0.9, // More smoothing for stable readings ...brightnessDetectorConfig, // Override with specific config }; brightnessDetectorRef.current = new RealtimeBrightnessDetector( handleLightingChange, brightnessConfig, translations // Pass translations for localized messages ); // Start without camera reference - uses ambient light sensor instead brightnessDetectorRef.current.start(); return () => { if (brightnessDetectorRef.current) { brightnessDetectorRef.current.stop(); } }; }, [ translations, metricsUpdateInterval, enableLightingMetrics, brightnessDetectorConfig, ]); // Include translations and update interval to recreate detector when they change // Initialize pitch detector useEffect(() => { if (!enableAngleMetrics) return; const handleAngleChange = (metrics: AngleMetrics) => { setAngleMetrics(metrics); addLog( `Angle: ${metrics.direction} (Roll: ${metrics.roll.toFixed( 1 )}°, Pitch: ${metrics.pitch.toFixed(1)}°, ${metrics.severity})` ); // Generate guidance message if in guidance mode if (isGuidanceMode && enableGuidanceMode) { const guidance = generateGuidanceMessage( metrics, yawMetrics, targetAngle, translations ); setGuidanceMessage(guidance); } // Vibration feedback for major tilts const now = Date.now(); if ( !metrics.isLevel && metrics.severity === "major" && now - lastVibrationRef.current > 2000 ) { Vibration.vibrate([100, 50, 100]); lastVibrationRef.current = now; } }; // Merge global update interval with pitch detector specific config const pitchConfig = { rollTolerance: 15, pitchTolerance: 15, pitchVertical: 90, updateInterval: metricsUpdateInterval, ...pitchDetectorConfig, // Override with specific config }; pitchDetectorRef.current = new PitchDetector( handleAngleChange, pitchConfig ); pitchDetectorRef.current.start(); return () => { if (pitchDetectorRef.current) { pitchDetectorRef.current.stop(); } if (durationInterval.current) { clearInterval(durationInterval.current); } }; }, [ metricsUpdateInterval, enableAngleMetrics, enableGuidanceMode, isGuidanceMode, yawMetrics, targetAngle, translations, pitchDetectorConfig, ]); // Pulse animation for recording button useEffect(() => { let pulseAnimation: Animated.CompositeAnimation; if (isRecording) { pulseAnimation = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.1, duration: 1000, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, duration: 1000, useNativeDriver: true, }), ]) ); pulseAnimation.start(); } else { pulseAnim.setValue(1); } return () => { if (pulseAnimation) { pulseAnimation.stop(); } }; }, [isRecording]); // Monitor and record guidance messages during recording useEffect(() => { if (!isRecording) return; // Record speed guidance messages if ( enableSpeedMetrics && speedMetrics.isMoving && speedMetrics.movementType !== "stationary" ) { recordInstructionEvent( "speed", "warning", getSpeedRecommendationMessage( speedMetrics.speed, speedMetrics.isMoving, translations ) ); } // Record motion guidance messages if (enableMotionMetrics && !motionMetrics.isStable) { recordInstructionEvent( "motion", motionMetrics.stability === "very_poor" ? "error" : "warning", getMotionStabilityMessage(motionMetrics.stability, translations) ); } // Record angle guidance messages if (enableAngleMetrics && !angleMetrics.isLevel) { recordInstructionEvent( "angle", angleMetrics.severity === "major" ? "warning" : "info", getAngleMessageTranslated(angleMetrics, translations) ); } // Record target angle guidance (only in guidance mode) if ( enableGuidanceMode && isGuidanceMode && guidanceMessage && guidanceMessage !== translations.perfectHoldSteady ) { recordInstructionEvent("guidance", "info", guidanceMessage); } // Record yaw guidance (only in guidance mode with target) if ( enableGuidanceMode && enableYawMetrics && isGuidanceMode && !yawMetrics.isOnTarget && targetAngle.yaw !== undefined ) { recordInstructionEvent( "yaw", "warning", getYawMessageTranslated(yawMetrics, translations) ); } }, [ isRecording, enableSpeedMetrics, speedMetrics.isMoving, speedMetrics.movementType, speedMetrics.speed, enableMotionMetrics, motionMetrics.isStable, motionMetrics.stability, enableAngleMetrics, angleMetrics.isLevel, angleMetrics.severity, enableGuidanceMode, isGuidanceMode, guidanceMessage, enableYawMetrics, yawMetrics.isOnTarget, targetAngle.yaw, translations, ]); // Recording duration tracking useEffect(() => { if (isRecording && recordingStartTime.current > 0) { durationInterval.current = setInterval(() => { const elapsed = Math.floor( (Date.now() - recordingStartTime.current) / 1000 ); setRecordingDuration(elapsed); }, 1000); } else if (durationInterval.current) { clearInterval(durationInterval.current); durationInterval.current = null; } return () => { if (durationInterval.current) { clearInterval(durationInterval.current); } }; }, [isRecording]); // Start recording - exactly like VideoRecorderApp const startRecording = async () => { if ( cameraRef.current && !isRecording && isCameraReady && isCameraInitialized ) { try { // Set current angle as target when recording starts (only if guidance mode is enabled) if (enableGuidanceMode) { const currentYaw = enableYawMetrics ? yawDetectorRef.current?.getCurrentYaw() : undefined; console.log("DEBUG: Auto-setting target on record start"); console.log("DEBUG: Current yaw from detector:", currentYaw); console.log("DEBUG: Current angle metrics:", angleMetrics); setTargetAngle({ roll: enableAngleMetrics ? angleMetrics.roll : 0, pitch: enableAngleMetrics ? angleMetrics.pitch : 0, yaw: currentYaw, }); // Set the target in the yaw detector if ( currentYaw !== undefined && yawDetectorRef.current && enableYawMetrics ) { yawDetectorRef.current.setTarget(currentYaw); console.log("DEBUG: Set yaw target to:", currentYaw); } setIsGuidanceMode(true); setGuidanceMessage(translations.targetSet); } // Clear any previous instruction events and throttling state setInstructionEvents([]); setLastInstructionTime({}); // Switch to video mode for recording setCameraMode("video"); // Longer delay on iOS to ensure camera is fully ready for video recording const delay = Platform.OS === "ios" ? 500 : 100; await new Promise((resolve) => setTimeout(resolve, delay)); // Additional check for iOS to ensure camera is ready if (Platform.OS === "ios") { // Wait a bit more and verify the camera ref is still valid await new Promise((resolve) => setTimeout(resolve, 200)); if (!cameraRef.current) { throw new Error("Camera reference lost during initialization"); } } setIsRecording(true); recordingStartTime.current = Date.now(); setRecordingDuration(0); console.log("Starting recording..."); const video = await cameraRef.current.recordAsync(); setRecordedVideo(video); console.log("Video recorded:", video?.uri); } catch (error) { console.error("Error recording video:", error); // More specific error handling for iOS camera readiness const errorMessage = error instanceof Error ? error.message : String(error); if ( errorMessage.includes("Camera is not ready yet") || errorMessage.includes("not ready") ) { Alert.alert( translations.errorRecording, Platform.OS === "ios" ? "Camera is still initializing. Please wait a moment and try again." : translations.failedToRecord ); } else { Alert.alert(translations.errorRecording, translations.failedToRecord); } } finally { setIsRecording(false); recordingStartTime.current = 0; setRecordingDuration(0); // Turn off guidance mode when recording stops (only if it was enabled) if (enableGuidanceMode) { setIsGuidanceMode(false); setGuidanceMessage(""); // Clear the yaw target if (yawDetectorRef.current && enableYawMetrics) { yawDetectorRef.current.clearTarget(); } } // Switch back to picture mode for brightness analysis setCameraMode("picture"); } } }; // Stop recording - exactly like VideoRecorderApp const stopRecording = async () => { if (cameraRef.current && isRecording) { try { console.log("Stopping recording..."); cameraRef.current.stopRecording(); // Mode will be switched back to picture in the finally block of startRecording } catch (error) { console.error("Error stopping recording:", error); // Ensure we switch back to picture mode even if stopping fails setCameraMode("picture"); } } }; // Save video to gallery or pass to callback - exactly like VideoRecorderApp const saveVideoToGallery = async () => { if (recordedVideo?.uri) { try { console.log("Processing video:", recordedVideo.uri); // If onVideoSave callback is provided, use it instead of saving to gallery if (onVideoSave) { const videoData: VideoData = { uri: recordedVideo.uri, duration: recordingDuration, instructionEvents: instructionEvents, // Include all recorded instruction events }; onVideoSave(videoData); setRecordedVideo(null); setInstructionEvents([]); // Clear instruction events after saving setLastInstructionTime({}); // Clear throttling state return; } // Original gallery saving logic (fallback) if (!hasMediaLibraryPermission) { Alert.alert( "Permission Required", "Please grant media library permission to save videos" ); return; } // Create asset const asset = await MediaLibrary.createAssetAsync(recordedVideo.uri); // Try to add to album const album = await MediaLibrary.getAlbumAsync("Videos"); if (album == null) { await MediaLibrary.createAlbumAsync("Videos", asset, false); } else { await MediaLibrary.addAssetsToAlbumAsync([asset], album, false); } Alert.alert(translations.success, translations.videoSaved); setRecordedVideo(null); } catch (error) { console.error("Error saving video:", error); Alert.alert(translations.errorRecording, translations.failedToSave); } } else { Alert.alert(translations.errorRecording, translations.noVideoToSave); } }; const discardVideo = () => { setRecordedVideo(null); setInstructionEvents([]); // Clear instruction events when discarding video setLastInstructionTime({}); // Clear throttling state }; // Combined toggle function - like VideoRecorderApp const toggleRecording = () => { if (isRecording) { stopRecording(); } else { // Additional camera readiness check for iOS if (!isCameraReady || !isCameraInitialized) { Alert.alert( translations.errorRecording, Platform.OS === "ios" ? "Camera is still initializing. Please wait a moment and try again." : "Camera is not ready yet. Please wait for camera to initialize." ); return; } // Ensure camera ref is valid if (!cameraRef.current) { Alert.alert( translations.errorRecording, "Camera reference is not available. Please restart the camera." ); return; } // Check motion stability before starting recording (only if enabled) if (enableMotionMetrics && !shouldAllowRecording(motionMetrics)) { Alert.alert(translations.motionTooHigh, translations.stabilizePhone, [ { text: "OK" }, ]); return; } // Check speed before starting recording (only if enabled) if (enableSpeedMetrics && !shouldAllowRecordingSpeed(speedMetrics)) { Alert.alert( translations.movementTooFast, `${ speedMetrics.recommendation }\nCurrent speed: ${speedMetrics.speedKmh.toFixed(1)} km/h`, [{ text: "OK" }] ); return; } startRecording(); } }; const onCameraReady = useCallback(() => { console.log("Camera is ready"); setIsCameraInitialized(true); // On iOS, add a small delay to ensure camera is fully initialized if (Platform.OS === "ios") { setTimeout(() => { setIsCameraReady(true); console.log("iOS camera ready state set after delay"); }, 300); } else { setIsCameraReady(true); } }, []); const toggleCameraFacing = useCallback(() => { if (!isRecording) { setFacing((current) => (current === "back" ? "front" : "back")); } }, [isRecording]); // Function to handle camera close const handleCameraClose = useCallback(() => { console.log("Camera close requested"); if (onCameraClose) { onCameraClose(); } }, [onCameraClose]); // Function to set current angle as target const setCurrentAsTarget = useCallback(() => { if (!enableGuidanceMode) return; console.log("DEBUG: setCurrentAsTarget called"); const currentYaw = enableYawMetrics ? yawDetectorRef.current?.getCurrentYaw() : undefined; console.log("DEBUG: Current yaw from detector:", currentYaw); console.log("DEBUG: Current angle metrics:", angleMetrics); setTargetAngle({ roll: enableAngleMetrics ? angleMetrics.roll : 0, pitch: enableAngleMetrics ? angleMetrics.pitch : 0, yaw: currentYaw, }); // Set the target in the yaw detector if ( currentYaw !== undefined && yawDetectorRef.current && enableYawMetrics ) { yawDetectorRef.current.setTarget(currentYaw); console.log("DEBUG: Set yaw target to:", currentYaw); } setIsGuidanceMode(true); setGuidanceMessage(translations.targetAngleSet); console.log("DEBUG: Target angle set to:", { roll: enableAngleMetrics ? angleMetrics.roll : 0, pitch: enableAngleMetrics ? angleMetrics.pitch : 0, yaw: currentYaw, }); }, [ angleMetrics.roll, angleMetrics.pitch, translations, enableGuidanceMode, enableAngleMetrics, enableYawMetrics, ]); // Function to toggle guidance mode const toggleGuidanceMode = useCallback(() => { if (!enableGuidanceMode) return; console.log( "DEBUG: toggleGuidanceMode called, current state:", isGuidanceMode ); setIsGuidanceMode(!isGuidanceMode); if (!isGuidanceMode) { setGuidanceMessage(translations.guidanceModeEnabled); console.log("DEBUG: Guidance mode enabled"); } else { setGuidanceMessage(""); console.log("DEBUG: Guidance mode disabled"); } }, [isGuidanceMode, translations, enableGuidanceMode]); // Function to reset to level target (0,0) const setLevelTarget = useCallback(() => { if (!enableGuidanceMode) return; console.log("DEBUG: setLevelTarget called"); setTargetAngle({ roll: 0, pitch: 0, yaw: undefined }); // Clear the target in the yaw detector if (yawDetectorRef.current && enableYawMetrics) { yawDetectorRef.current.clearTarget(); console.log("DEBUG: Cleared yaw target"); } setIsGuidanceMode(true); setGuidanceMessage(translations.targetSetToLevel); console.log("DEBUG: Target set to level (0, 0, undefined)"); }, [translations, enableGuidanceMode, enableYawMetrics]); // Permission check if (!permission) { return ; } if (!permission.granted) { return ( {translations.cameraPermissionMessage} {translations.grantPermission} ); } const formatRecordingTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, "0")}:${secs .toString() .padStart(2, "0")}`; }; const renderAngleIndicator = () => { const centerX = screenWidth / 2; const centerY = 80; const radius = 24; const bubbleRadius = 5; const maxOffset = radius - bubbleRadius; const offsetX = (angleMetrics.roll / 60) * maxOffset; const offsetY = (-angleMetrics.pitch / 60) * maxOffset; const bubbleX = centerX + Math.max(-maxOffset, Math.min(maxOffset, offsetX)); const bubbleY = centerY + Math.max(-maxOffset, Math.min(maxOffset, offsetY)); return ( {`Roll: ${Math.abs(angleMetrics.roll).toFixed(1)}° Pitch: ${Math.abs( angleMetrics.pitch ).toFixed(1)}°`} ); }; const renderBalanceIndicator = () => { const centerX = screenWidth / 2; const indicatorY = 100; const barWidth = 120; const barHeight = 4; const rollRatio = angleMetrics.roll / 60; const indicatorX = centerX + rollRatio * (barWidth / 2); const pitchRatio = angleMetrics.pitch / 60; const indicatorY2 = indicatorY + 20; const indicatorY2Bar = indicatorY2; const indicatorY2Circle = indicatorY2Bar + barHeight / 2; const indicatorX2 = centerX + pitchRatio * (barWidth / 2); // Target indicators const targetRollRatio = targetAngle.roll / 60; const targetIndicatorX = centerX + targetRollRatio * (barWidth / 2); const targetPitchRatio = targetAngle.pitch / 60; const targetIndicatorX2 = centerX + targetPitchRatio * (barWidth / 2); return ( {/* Roll indicator bar */} {/* Target indicator for roll (if guidance mode is on) */} {isGuidanceMode && ( )} {/* Current position indicator for roll */} L R {/* Pitch indicator bar */} {/* Target indicator for pitch (if guidance mode is on) */} {isGuidanceMode && ( )} {/* Current position indicator for pitch */} B F ); }; const renderCompassIndicator = () => { if (!isGuidanceMode || targetAngle.yaw === undefined) { return null; } const centerX = screenWidth / 2; const centerY = 200; const radius = 30; const needleLength = 20; // Calculate current yaw needle position const currentRadians = (yawMetrics.yaw * Math.PI) / 180; const currentNeedleX = centerX + Math.sin(currentRadians) * needleLength; const currentNeedleY = centerY - Math.cos(currentRadians) * needleLength; // Calculate target yaw needle position const targetRadians = (targetAngle.yaw * Math.PI) / 180; const targetNeedleX = centerX + Math.sin(targetRadians) * needleLength; const targetNeedleY = centerY - Math.cos(targetRadians) * needleLength; return ( {/* Compass circle */} {/* Target direction (green) */} {/* Current direction (white/red) */} {/* North indicator */} N {/* Compass label */} {`${Math.round(yawMetrics.yaw)}° / ${Math.round(targetAngle.yaw)}°`} ); }; return ( {/* Overlay content - only show when recording */} {isRecording && ( {enableAngleMetrics && renderAngleIndicator()} {enableAngleMetrics && renderBalanceIndicator()} {enableGuidanceMode && enableYawMetrics && renderCompassIndicator()} )} {/* Recording indicator */} {isRecording && ( {translations.recording} {formatRecordingTime(recordingDuration)} )} {/* Camera ready indicator */} {!isCameraReady && ( {Platform.OS === "ios" ? "Initializing camera..." : translations.preparing} )} {/* Status bar - only show when recording */} {isRecording && ( {enableAngleMetrics && ( {translations.pitch} {angleMetrics.isLevel ? translations.level : translations.tilted} {Math.abs(angleMetrics.pitch).toFixed(1)}° )} {enableMotionMetrics && ( {translations.motionScore} {getQualityTranslation(motionMetrics.stability)} {motionMetrics.score} )} {/* Distance indicator commented out */} {/* {translations.distance} {translations.bad} 4.32m */} {enableGuidanceMode && enableYawMetrics && isGuidanceMode && targetAngle.yaw !== undefined && ( {translations.compass} {yawMetrics.isOnTarget ? translations.onTrack : translations.turnBody} {`${Math.round(yawMetrics.yaw)}°`} )} {enableSpeedMetrics && ( {translations.speed} {speedMetrics.movementType === "stationary" ? translations.stationary : speedMetrics.movementType} {speedMetrics.speedKmh.toFixed(1)} km/h )} {enableLightingMetrics && ( {translations.brightness} {getQualityTranslation(lightingMetrics.quality)} {Math.round(lightingMetrics.meanLuminance)} )} )} {/* Detection Frame */} {/* */} {/* Guidance message - only show when recording */} {isRecording && ( {/* Speed guidance - appears at top when active (most urgent) */} {enableSpeedMetrics && speedMetrics.isMoving && speedMetrics.movementType !== "stationary" && ( {getSpeedRecommendationMessage( speedMetrics.speed, speedMetrics.isMoving, translations )} )} {/* Motion guidance - appears above angle guidance when unstable */} {enableMotionMetrics && !motionMetrics.isStable && ( {getMotionStabilityMessage( motionMetrics.stability, translations )} )} {/* Target angle guidance - main guidance when in guidance mode */} {enableGuidanceMode && isGuidanceMode && guidanceMessage && ( {guidanceMessage} )} {/* Angle guidance - basic level guidance when not in guidance mode */} {enableAngleMetrics && !isGuidanceMode && ( {getAngleMessageTranslated(angleMetrics, translations)} )} )} {/* Bottom Controls */} {/* Left side - Close button */} {/* Center - Record button or video actions */} {recordedVideo ? ( {/* Discard */} {/* Save */} ) : ( )} {/* Right side - Camera flip button */} {/* Metrics Logs Overlay */} {onScreen && ( {translations.metricsLogs} setMetricsLogs([])} > {translations.clear} {metricsLogs.length === 0 ? ( {translations.noLogsYet} ) : ( metricsLogs.slice(0, 15).map((log, index) => ( {log} )) )} )} ); }; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", }, message: { textAlign: "center", paddingBottom: 10, fontSize: 16, color: "#333", }, camera: { flex: 1, }, recordingIndicator: { position: "absolute", top: 60, left: 16, flexDirection: "row", alignItems: "center", backgroundColor: "rgba(255, 68, 68, 0.9)", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, }, recordingDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#FFFFFF", marginRight: 8, }, recordingText: { color: "#FFFFFF", fontSize: 12, fontWeight: "bold", }, cameraNotReady: { position: "absolute", top: 60, right: 16, backgroundColor: "rgba(255, 165, 0, 0.8)", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, }, cameraNotReadyText: { color: "#FFFFFF", fontSize: 12, fontWeight: "bold", }, topBar: { position: "absolute", top: screenHeight * 0.12, left: 16, right: 16, flexDirection: "row", justifyContent: "space-between", }, statusItem: { borderRadius: 8, }, statusLabel: { color: "#FFF", fontSize: 12, textTransform: "uppercase", fontWeight: "bold", }, statusComment: { color: "#FFF", textTransform: "capitalize", fontSize: 14, fontWeight: "bold", }, statusValue: { color: "#FFF", fontSize: 14, fontWeight: "bold", }, guidanceContainer: { position: "absolute", top: screenHeight * 0.72, left: 16, right: 16, flexDirection: "column-reverse", }, bottomControllsButton: { width: 48, height: 48, borderRadius: 48, backgroundColor: "rgba(0,0,0,0.2)", alignItems: "center", justifyContent: "center", }, guidanceItem: { paddingHorizontal: 16, paddingVertical: 8, marginVertical: 2, borderRadius: 8, alignItems: "center", }, guidanceText: { color: "#FFFFFF", fontSize: 14, fontWeight: "bold", }, button: { backgroundColor: "#2196F3", paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, marginHorizontal: 10, }, buttonText: { color: "#FFFFFF", fontSize: 16, fontWeight: "bold", }, angleIndicator: { position: "absolute", top: -10, left: 0, right: 0, }, balanceIndicator: { position: "absolute", top: 100, left: 0, right: 0, }, compassIndicator: { position: "absolute", top: 50, left: 100, right: 0, }, detectionFrame: { position: "absolute", top: screenHeight * 0.2, left: 32, right: 32, bottom: screenHeight * 0.25, borderWidth: 2, borderColor: "rgba(255, 255, 255, 0.5)", borderStyle: "dashed", }, frameCorner: { position: "absolute", width: 20, height: 20, borderLeftWidth: 3, borderTopWidth: 3, borderColor: "#FFFFFF", top: -2, left: -2, }, frameCornerTopRight: { transform: [{ rotate: "90deg" }], top: -2, right: -2, left: undefined, }, frameCornerBottomLeft: { transform: [{ rotate: "-90deg" }], bottom: -2, top: undefined, left: -2, }, frameCornerBottomRight: { transform: [{ rotate: "180deg" }], bottom: -2, right: -2, top: undefined, left: undefined, }, bottomControls: { position: "absolute", bottom: 50, left: 0, right: 0, flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 50, }, videoActions: { flexDirection: "row", alignItems: "center", gap: 30, }, actionButton: { alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.5)", paddingVertical: 15, paddingHorizontal: 20, borderRadius: 25, }, actionText: { color: "white", fontSize: 12, marginTop: 5, }, recordButton: { width: 70, height: 70, borderRadius: 35, borderWidth: 4, borderColor: "#FFFFFF", alignItems: "center", justifyContent: "center", }, recordButtonInner: { width: 50, height: 50, borderRadius: 25, }, logsContainer: { position: "absolute", top: 60, left: 16, right: 16, bottom: 120, backgroundColor: "rgba(0, 0, 0, 0.85)", borderRadius: 12, padding: 16, }, logsHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 12, paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: "rgba(255, 255, 255, 0.2)", }, logsTitle: { color: "#FFFFFF", fontSize: 16, fontWeight: "bold", }, clearLogsButton: { backgroundColor: "rgba(255, 255, 255, 0.2)", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, }, clearLogsText: { color: "#FFFFFF", fontSize: 12, fontWeight: "bold", }, logsList: { flex: 1, }, noLogsText: { color: "rgba(255, 255, 255, 0.6)", fontSize: 14, textAlign: "center", marginTop: 20, fontStyle: "italic", }, logEntry: { color: "#FFFFFF", fontSize: 11, marginBottom: 4, paddingVertical: 2, paddingHorizontal: 8, backgroundColor: "rgba(255, 255, 255, 0.1)", borderRadius: 4, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", }, // RTL styles topBarRTL: { flexDirection: "row-reverse", }, textRTL: { textAlign: "right", writingDirection: "rtl", }, }); export default GuidedCameraView;