import React, { useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Image, Alert, Animated, Easing, } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import * as ImageManipulator from 'expo-image-manipulator'; import * as Haptics from 'expo-haptics'; import * as AppleAuthentication from 'expo-apple-authentication'; import Constants from 'expo-constants'; import { ShareIntentModule } from 'expo-share-intent'; import { useSafeAnalytics } from '../services/analytics'; import { useApp } from '../context/AppContext'; import { useColors } from '../constants/Colors'; import { PlantRecognitionService } from '../services/plantRecognitionService'; import { IdentificationResult } from '../types'; import { ResultCard } from '../components/ResultCard'; import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient'; import { isBackendApiError } from '../services/backend/contracts'; import { createIdempotencyKey } from '../utils/idempotency'; import { AuthService } from '../services/authService'; import { getMockPlantByImage } from '../services/backend/mockCatalog'; import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff'; const HEALTH_CHECK_CREDIT_COST = 2; const DEMO_SCAN_LIMIT = 5; const getBillingCopy = (language: 'de' | 'en' | 'es') => { if (language === 'de') { return { creditsLabel: 'Credits', noCreditsTitle: 'Keine Credits mehr', noCreditsMessage: 'Du hast keine Credits mehr fuer KI-Scans. Upgrade oder Top-up im Profil.', healthNoCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, managePlan: 'Plan verwalten', dismiss: 'Schliessen', genericErrorTitle: 'Fehler', genericErrorMessage: 'Analyse fehlgeschlagen.', noConnectionTitle: 'Keine Verbindung', noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.', timeoutTitle: 'Scan zu langsam', timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.', retryLabel: 'Erneut versuchen', notAPlantTitle: 'Keine Pflanze erkannt', notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.', providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.', healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check abgeschlossen', healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.', signupLabel: 'Registrieren', demoTitle: 'Rettungsplan bereit', demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.', demoNoCreditsTitle: 'Demo-Scans aufgebraucht', demoNoCreditsMessage: 'Du hast deine 5 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.', demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`, appleCta: 'Mit Apple fortfahren', emailCta: 'Mit E-Mail fortfahren', unlockCta: 'Vollständige Diagnose freischalten', }; } if (language === 'es') { return { creditsLabel: 'Creditos', noCreditsTitle: 'Sin creditos', noCreditsMessage: 'No tienes creditos para escaneos AI. Actualiza o compra top-up en Perfil.', healthNoCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, managePlan: 'Gestionar plan', dismiss: 'Cerrar', genericErrorTitle: 'Error', genericErrorMessage: 'Analisis fallido.', noConnectionTitle: 'Sin conexión', noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.', timeoutTitle: 'Escaneo lento', timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.', retryLabel: 'Reintentar', notAPlantTitle: 'No es una planta', notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.', providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.', healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.', healthTitle: 'Health Check', healthDoneTitle: 'Health-check completado', healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.', signupLabel: 'Registrarse', demoTitle: 'Plan de rescate listo', demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.', demoNoCreditsTitle: 'Escaneos demo agotados', demoNoCreditsMessage: 'Ya usaste tus 5 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.', demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`, appleCta: 'Continuar con Apple', emailCta: 'Continuar con email', unlockCta: 'Desbloquear diagnóstico completo', }; } return { creditsLabel: 'Credits', noCreditsTitle: 'No credits left', noCreditsMessage: 'You have no AI scan credits left. Upgrade or buy a top-up in Profile.', healthNoCreditsMessage: `You need ${HEALTH_CHECK_CREDIT_COST} credits for the health check.`, managePlan: 'Manage plan', dismiss: 'Close', genericErrorTitle: 'Error', genericErrorMessage: 'Analysis failed.', noConnectionTitle: 'No connection', noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.', timeoutTitle: 'Scan Too Slow', timeoutMessage: 'Analysis took too long. Please try again.', retryLabel: 'Try again', notAPlantTitle: 'No plant detected', notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.', providerErrorMessage: 'AI scan is currently unavailable. Please try again.', healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check Complete', healthDoneMessage: 'The new photo was analyzed and added to gallery.', signupLabel: 'Sign Up', demoTitle: 'Rescue plan ready', demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.', demoNoCreditsTitle: 'Demo scans used', demoNoCreditsMessage: 'You used your 5 free demo scans on this device. Start Pro to keep scanning plants.', demoCreditsRemaining: (count: number) => `${count} demo scans left`, appleCta: 'Continue with Apple', emailCta: 'Continue with email', unlockCta: 'Unlock full diagnosis', }; }; export default function ScannerScreen() { const params = useLocalSearchParams<{ mode?: string; plantId?: string; sharedImageKey?: string; sharedImageUri?: string }>(); const posthog = useSafeAnalytics(); const { isDarkMode, colorPalette, language, t, savePlant, plants, updatePlant, billingSummary, refreshBillingSummary, isLoadingBilling, session, hydrateSession, setPendingPlant, guestScanCount, incrementGuestScanCount, } = useApp(); const colors = useColors(isDarkMode, colorPalette); const router = useRouter(); const insets = useSafeAreaInsets(); const billingCopy = getBillingCopy(language); const isHealthMode = params.mode === 'health'; const healthPlantId = Array.isArray(params.plantId) ? params.plantId[0] : params.plantId; const healthPlant = isHealthMode && healthPlantId ? plants.find((item) => item.id === healthPlantId) : null; const sharedImageUri = Array.isArray(params.sharedImageUri) ? params.sharedImageUri[0] : params.sharedImageUri; const sharedImageKey = Array.isArray(params.sharedImageKey) ? params.sharedImageKey[0] : params.sharedImageKey; const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'; const isDemoMode = !hasActiveEntitlement; const availableCredits = hasActiveEntitlement ? (billingSummary?.credits.available ?? 0) : 0; const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); const [permission, requestPermission] = useCameraPermissions(); const [selectedImage, setSelectedImage] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAuthLoading, setIsAuthLoading] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(0); const [analysisResult, setAnalysisResult] = useState(null); const [demoResultVisible, setDemoResultVisible] = useState(false); const cameraRef = useRef(null); const scanLineProgress = useRef(new Animated.Value(0)).current; const scanPulse = useRef(new Animated.Value(0)).current; const isExpoGo = Constants.appOwnership === 'expo'; useEffect(() => { if (isExpoGo) { setAppleAvailable(false); return; } let mounted = true; AppleAuthentication.isAvailableAsync() .then((available) => { if (mounted) setAppleAvailable(available); }) .catch(() => { if (mounted) setAppleAvailable(false); }); return () => { mounted = false; }; }, [isExpoGo]); const lastProcessedShareToken = useRef(null); const sharedAnalysisInFlightToken = useRef(null); const resizeForAnalysisRef = useRef<(uri: string) => Promise>(async (uri) => uri); const analyzeImageRef = useRef<(imageUri: string, galleryImageUri?: string) => Promise>(async () => {}); useEffect(() => { if (!isAnalyzing) { scanLineProgress.stopAnimation(); scanLineProgress.setValue(0); scanPulse.stopAnimation(); scanPulse.setValue(0); return; } const lineAnimation = Animated.loop( Animated.sequence([ Animated.timing(scanLineProgress, { toValue: 1, duration: 1500, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), Animated.timing(scanLineProgress, { toValue: 0, duration: 1500, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), ]) ); const pulseAnimation = Animated.loop( Animated.sequence([ Animated.timing(scanPulse, { toValue: 1, duration: 900, useNativeDriver: true }), Animated.timing(scanPulse, { toValue: 0, duration: 900, useNativeDriver: true }), ]) ); lineAnimation.start(); pulseAnimation.start(); return () => { lineAnimation.stop(); pulseAnimation.stop(); }; }, [isAnalyzing, scanLineProgress, scanPulse]); const resizeForAnalysis = async (uri: string): Promise => { if (uri.startsWith('data:')) return uri; try { const result = await ImageManipulator.manipulateAsync( uri, [{ resize: { width: 1280 } }], { compress: 0.9, format: ImageManipulator.SaveFormat.JPEG, base64: true }, ); return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri; } catch { return uri; } }; const analyzeImage = async (imageUri: string, galleryImageUri?: string) => { if (isAnalyzing) return; if (isDemoMode && guestScanCount >= DEMO_SCAN_LIMIT) { Alert.alert( billingCopy.demoNoCreditsTitle, billingCopy.demoNoCreditsMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.managePlan, onPress: () => router.replace('/profile/billing'), }, ], ); return; } if (!isDemoMode && availableCredits <= 0) { Alert.alert( billingCopy.noCreditsTitle, isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.managePlan, onPress: () => router.replace('/profile/billing'), }, ], ); return; } setIsAnalyzing(true); setAnalysisProgress(0); setAnalysisResult(null); setDemoResultVisible(false); const startTime = Date.now(); const progressInterval = setInterval(() => { setAnalysisProgress((prev) => { if (prev < 30) return prev + Math.random() * 8; if (prev < 70) return prev + Math.random() * 2; if (prev < 90) return prev + 0.5; return prev; }); }, 150); try { if (isDemoMode) { posthog.capture('demo_scan_started', { authenticated: Boolean(session), scan_type: isHealthMode ? 'health_check' : 'identification', demo_scans_used: guestScanCount, demo_scans_remaining: demoScansRemaining, }); await new Promise(resolve => setTimeout(resolve, 2100)); setAnalysisProgress(100); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); await new Promise(resolve => setTimeout(resolve, 350)); const demoResult = getMockPlantByImage(galleryImageUri || imageUri, language, true); incrementGuestScanCount(); setAnalysisResult(demoResult); posthog.capture('demo_scan_completed', { authenticated: Boolean(session), latency_ms: Date.now() - startTime, demo_scans_used_after: guestScanCount + 1, }); return; } posthog.capture('paid_scan_started', { scan_type: isHealthMode ? 'health_check' : 'identification', credits_available: availableCredits, }); if (isHealthMode) { if (!healthPlant) { Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); setSelectedImage(null); setIsAnalyzing(false); return; } const response = await backendApiClient.runHealthCheck({ idempotencyKey: createIdempotencyKey('health-check', healthPlant.id), imageUri, language, plantContext: { name: healthPlant.name, botanicalName: healthPlant.botanicalName, careInfo: healthPlant.careInfo, description: healthPlant.description, }, }); posthog.capture('llm_generation', { scan_type: 'health_check', success: true, latency_ms: Date.now() - startTime, }); const currentGallery = healthPlant.gallery || []; const existingChecks = healthPlant.healthChecks || []; const updatedChecks = [response.healthCheck, ...existingChecks].slice(0, 6); const updatedPlant = { ...healthPlant, gallery: galleryImageUri ? [...currentGallery, galleryImageUri] : currentGallery, healthChecks: updatedChecks, }; await updatePlant(updatedPlant); } else { const result = await PlantRecognitionService.identify(imageUri, language, { idempotencyKey: createIdempotencyKey('scan-plant'), }); posthog.capture('llm_generation', { scan_type: 'identification', success: true, latency_ms: Date.now() - startTime, }); setAnalysisResult(result); } setAnalysisProgress(100); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); posthog.capture('paid_scan_completed', { scan_type: isHealthMode ? 'health_check' : 'identification', latency_ms: Date.now() - startTime, }); await new Promise(resolve => setTimeout(resolve, 500)); setIsAnalyzing(false); if (isHealthMode && healthPlant) { Alert.alert(billingCopy.healthDoneTitle, billingCopy.healthDoneMessage, [ { text: billingCopy.dismiss, onPress: () => router.replace(`/plant/${healthPlant.id}`) }, ]); } } catch (error) { console.error('Analysis failed', error); posthog.capture('llm_generation', { scan_type: isHealthMode ? 'health_check' : 'identification', success: false, error_type: isInsufficientCreditsError(error) ? 'insufficient_credits' : 'provider_error', latency_ms: Date.now() - startTime, }); if (isInsufficientCreditsError(error)) { Alert.alert( billingCopy.noCreditsTitle, isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.managePlan, onPress: () => router.replace('/profile/billing'), }, ], ); } else if (isTimeoutError(error)) { Alert.alert( billingCopy.timeoutTitle, billingCopy.timeoutMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, ], ); } else if (isNetworkError(error)) { Alert.alert( billingCopy.noConnectionTitle, billingCopy.noConnectionMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, ], ); } else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') { Alert.alert( billingCopy.notAPlantTitle, billingCopy.notAPlantMessage, [{ text: billingCopy.dismiss, style: 'cancel' }], ); } else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') { Alert.alert( billingCopy.genericErrorTitle, isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) }, ], ); } else { Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); } setSelectedImage(null); setIsAnalyzing(false); } finally { clearInterval(progressInterval); setIsAnalyzing(false); if (!isDemoMode) { await refreshBillingSummary(); } } }; useEffect(() => { resizeForAnalysisRef.current = resizeForAnalysis; analyzeImageRef.current = analyzeImage; }); useEffect(() => { const shareToken = sharedImageKey || sharedImageUri; if (!shareToken || isLoadingBilling || isAnalyzing) return; if (lastProcessedShareToken.current === shareToken) return; if (sharedAnalysisInFlightToken.current) return; const handoffImageUri = consumeSharedImageUri(sharedImageKey); const nextSharedImageUri = handoffImageUri || sharedImageUri; if (!nextSharedImageUri) return; lastProcessedShareToken.current = shareToken; sharedAnalysisInFlightToken.current = shareToken; ShareIntentModule?.clearShareIntent(SHARE_INTENT_KEY); let cancelled = false; (async () => { try { const analysisUri = await resizeForAnalysisRef.current(nextSharedImageUri); if (cancelled || sharedAnalysisInFlightToken.current !== shareToken) return; setDemoResultVisible(false); setSelectedImage(analysisUri); await analyzeImageRef.current(analysisUri, nextSharedImageUri); } finally { if (sharedAnalysisInFlightToken.current === shareToken) { sharedAnalysisInFlightToken.current = null; } } })(); return () => { cancelled = true; }; }, [sharedImageKey, sharedImageUri, isLoadingBilling, isAnalyzing]); const takePicture = async () => { if (!cameraRef.current || isAnalyzing) return; await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 }); if (photo) { const analysisUri = await resizeForAnalysis(photo.uri); setDemoResultVisible(false); setSelectedImage(analysisUri); analyzeImage(analysisUri, photo.uri); } }; const pickImage = async () => { if (isAnalyzing) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 1, base64: false, }); if (!result.canceled && result.assets[0]) { const asset = result.assets[0]; const analysisUri = await resizeForAnalysis(asset.uri); setDemoResultVisible(false); setSelectedImage(asset.uri); analyzeImage(analysisUri, asset.uri); } }; const handleSave = async () => { if (analysisResult && selectedImage) { if (!session) { // Guest mode: store result and go to signup setPendingPlant(analysisResult, selectedImage); router.replace('/auth/signup'); return; } try { await savePlant(analysisResult, selectedImage); if (router.canGoBack()) { router.back(); } else { router.replace('/(tabs)'); } } catch (error) { console.error('Saving identified plant failed', error); Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); } } }; const routeToHardPaywall = () => { posthog.capture('auth_prompt_shown', { authenticated: Boolean(session), surface: 'demo_scan_result', }); if (session) { router.replace('/profile/billing'); return; } router.replace('/auth/signup'); }; const handleDemoAppleSignIn = async () => { if (!appleAvailable) { routeToHardPaywall(); return; } setIsAuthLoading(true); posthog.capture('apple_login_started', { surface: 'scanner_demo' }); try { const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); if (!credential.identityToken) { throw new Error('APPLE_AUTH_INVALID'); } const fullName = [ credential.fullName?.givenName, credential.fullName?.familyName, ].filter(Boolean).join(' '); const nextSession = await AuthService.signInWithApple({ identityToken: credential.identityToken, appleUser: credential.user, email: credential.email, name: fullName || undefined, }); await hydrateSession(nextSession); posthog.capture('apple_login_succeeded', { surface: 'scanner_demo' }); router.replace(nextSession.isNewUser ? '/onboarding/source' : '/(tabs)'); } catch (error: any) { if (error?.code === 'ERR_REQUEST_CANCELED') { return; } posthog.capture('apple_login_failed', { surface: 'scanner_demo', error: error instanceof Error ? error.message : String(error), }); Alert.alert( billingCopy.genericErrorTitle, error instanceof Error && error.message === 'APPLE_BACKEND_UNAVAILABLE' ? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.' : billingCopy.genericErrorMessage, ); } finally { setIsAuthLoading(false); } }; const handleClose = () => { if (router.canGoBack()) { router.back(); return; } router.replace('/onboarding'); }; const controlsPaddingBottom = Math.max(20, insets.bottom + 10); const controlsPanelHeight = 28 + 80 + controlsPaddingBottom; const analysisBottomOffset = controlsPanelHeight + 12; const scanLineTranslateY = scanLineProgress.interpolate({ inputRange: [0, 1], outputRange: [24, 280], }); const scanPulseScale = scanPulse.interpolate({ inputRange: [0, 1], outputRange: [0.98, 1.02], }); const scanPulseOpacity = scanPulse.interpolate({ inputRange: [0, 1], outputRange: [0.22, 0.55], }); // Show result if (!isHealthMode && analysisResult && selectedImage) { return ( ); } // Camera permission if (!permission?.granted) { return ( Camera access is required to scan plants. Continue ); } return ( {/* Header */} {isHealthMode ? billingCopy.healthTitle : t.scanner} {isDemoMode ? billingCopy.demoCreditsRemaining(demoScansRemaining) : `${billingCopy.creditsLabel}: ${availableCredits}`} {/* Camera */} {selectedImage ? ( ) : ( )} {/* Scan Frame */} {selectedImage && ( )} {isAnalyzing && ( <> )} {/* Analyzing Overlay */} {isAnalyzing && ( {analysisProgress < 100 ? t.analyzing : t.result} {Math.round(analysisProgress)}% {t.aiProcessing} {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} )} {demoResultVisible && !isAnalyzing ? ( {billingCopy.demoTitle} {billingCopy.demoMessage} {!session && appleAvailable ? ( ) : ( {isAuthLoading ? '...' : session ? billingCopy.unlockCta : appleAvailable ? billingCopy.appleCta : billingCopy.emailCta} )} {!session ? ( { posthog.capture('auth_prompt_shown', { surface: 'demo_scan_result', method: 'email' }); router.replace('/auth/signup'); }} activeOpacity={0.85} > {billingCopy.emailCta} ) : null} ) : null} {/* Bottom Controls */} {t.gallery} {t.help} ); } const styles = StyleSheet.create({ container: { flex: 1 }, header: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingHorizontal: 24, }, headerTitle: { fontSize: 18, fontWeight: '600' }, creditBadge: { borderWidth: 1, borderRadius: 14, paddingHorizontal: 8, paddingVertical: 4, flexDirection: 'row', alignItems: 'center', gap: 4, }, creditBadgeText: { fontSize: 10, fontWeight: '700' }, cameraContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, scanFrame: { width: 256, height: 320, borderWidth: 2.5, borderColor: '#ffffff50', borderRadius: 28, overflow: 'hidden', }, scanPulseFrame: { ...StyleSheet.absoluteFillObject, borderWidth: 1.5, borderRadius: 28, }, scanLine: { position: 'absolute', left: 16, right: 16, height: 2, borderRadius: 999, shadowColor: '#ffffff', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.8, shadowRadius: 8, elevation: 6, }, corner: { position: 'absolute', width: 24, height: 24 }, tl: { top: 16, left: 16, borderTopWidth: 4, borderLeftWidth: 4, borderTopLeftRadius: 12 }, tr: { top: 16, right: 16, borderTopWidth: 4, borderRightWidth: 4, borderTopRightRadius: 12 }, bl: { bottom: 16, left: 16, borderBottomWidth: 4, borderLeftWidth: 4, borderBottomLeftRadius: 12 }, br: { bottom: 16, right: 16, borderBottomWidth: 4, borderRightWidth: 4, borderBottomRightRadius: 12 }, controls: { borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingHorizontal: 32, paddingTop: 28, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, controlBtn: { alignItems: 'center', gap: 6 }, controlBtnDisabled: { opacity: 0.5 }, controlLabel: { fontSize: 11, fontWeight: '500' }, shutterBtn: { width: 80, height: 80, borderRadius: 40, borderWidth: 4, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, }, shutterInner: { width: 64, height: 64, borderRadius: 32 }, shutterBtnDisabled: { opacity: 0.6 }, analysisSheet: { position: 'absolute', left: 16, right: 16, borderRadius: 20, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 14, zIndex: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.28, shadowRadius: 14, elevation: 14, }, demoSheet: { position: 'absolute', left: 16, right: 16, borderRadius: 22, borderWidth: 1, padding: 18, zIndex: 25, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.24, shadowRadius: 12, elevation: 12, }, demoIconWrap: { width: 42, height: 42, borderRadius: 21, justifyContent: 'center', alignItems: 'center', marginBottom: 10, }, demoTitle: { fontSize: 20, fontWeight: '800', marginBottom: 6, }, demoMessage: { fontSize: 14, lineHeight: 20, marginBottom: 14, }, demoAppleButton: { width: '100%', height: 50, marginBottom: 10, }, demoPrimaryBtn: { height: 50, borderRadius: 12, alignItems: 'center', justifyContent: 'center', marginBottom: 10, }, demoPrimaryText: { fontSize: 15, fontWeight: '800', }, demoSecondaryBtn: { height: 48, borderRadius: 12, borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, demoSecondaryText: { fontSize: 14, fontWeight: '700', }, analysisHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, analysisBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 5, }, analysisLabel: { fontWeight: '700', fontSize: 12, letterSpacing: 0.2 }, analysisPercent: { fontFamily: 'monospace', fontSize: 12, fontWeight: '700' }, progressBg: { height: 9, borderRadius: 999, overflow: 'hidden', marginBottom: 10 }, progressFill: { height: '100%', borderRadius: 4 }, analysisFooter: { gap: 4 }, analysisStatusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, statusDot: { width: 8, height: 8, borderRadius: 4 }, analysisStage: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, analysisStageDetail: { fontSize: 11, lineHeight: 16, fontWeight: '500' }, permissionContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }, permissionText: { fontSize: 16, textAlign: 'center', marginBottom: 20 }, permissionBtn: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 }, permissionBtnText: { fontWeight: '700', fontSize: 15 }, });