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 Haptics from 'expo-haptics'; import { usePostHog } from 'posthog-react-native'; 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 } from '../services/backend/backendApiClient'; import { isBackendApiError } from '../services/backend/contracts'; import { createIdempotencyKey } from '../utils/idempotency'; const HEALTH_CHECK_CREDIT_COST = 2; 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.', providerErrorMessage: 'KI-Scan gerade nicht verfuegbar. Bitte API-Key/Netzwerk pruefen.', healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfuegbar. Bitte API-Key/Netzwerk pruefen.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check abgeschlossen', healthDoneMessage: 'Neues Foto wurde geprueft und zur Galerie hinzugefuegt.', signupLabel: 'Registrieren', }; } 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.', providerErrorMessage: 'Escaneo IA no disponible ahora. Revisa API key y red.', healthProviderErrorMessage: 'Health-check IA no disponible ahora. Revisa API key y red.', healthTitle: 'Health Check', healthDoneTitle: 'Health-check completado', healthDoneMessage: 'La foto nueva fue analizada y guardada en la galeria.', signupLabel: 'Registrarse', }; } 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.', providerErrorMessage: 'AI scan is unavailable right now. Check API key and network.', healthProviderErrorMessage: 'AI health check is unavailable right now. Check API key and network.', healthTitle: 'Health Check', healthDoneTitle: 'Health Check Complete', healthDoneMessage: 'The new photo was analyzed and added to gallery.', signupLabel: 'Sign Up', }; }; export default function ScannerScreen() { const params = useLocalSearchParams<{ mode?: string; plantId?: string }>(); const posthog = usePostHog(); const { isDarkMode, colorPalette, language, t, savePlant, plants, updatePlant, billingSummary, refreshBillingSummary, isLoadingBilling, session, 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 availableCredits = session ? (billingSummary?.credits.available ?? 0) : Math.max(0, 5 - guestScanCount); const [permission, requestPermission] = useCameraPermissions(); const [selectedImage, setSelectedImage] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analysisProgress, setAnalysisProgress] = useState(0); const [analysisResult, setAnalysisResult] = useState(null); const cameraRef = useRef(null); const scanLineProgress = useRef(new Animated.Value(0)).current; const scanPulse = useRef(new Animated.Value(0)).current; 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 analyzeImage = async (imageUri: string, galleryImageUri?: string) => { if (isAnalyzing) return; if (availableCredits <= 0) { Alert.alert( billingCopy.noCreditsTitle, isHealthMode ? billingCopy.healthNoCreditsMessage : billingCopy.noCreditsMessage, [ { text: billingCopy.dismiss, style: 'cancel' }, { text: billingCopy.signupLabel, onPress: () => router.push('/auth/signup'), }, ], ); return; } setIsAnalyzing(true); setAnalysisProgress(0); setAnalysisResult(null); 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 (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, }); if (!session) { incrementGuestScanCount(); } 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, }); if (!session) { incrementGuestScanCount(); } setAnalysisResult(result); } setAnalysisProgress(100); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); 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('/(tabs)/profile'), }, ], ); } else if ( isBackendApiError(error) && (error.code === 'PROVIDER_ERROR' || error.code === 'TIMEOUT') ) { Alert.alert( billingCopy.genericErrorTitle, isHealthMode ? billingCopy.healthProviderErrorMessage : billingCopy.providerErrorMessage, ); } else { Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); } setSelectedImage(null); setIsAnalyzing(false); } finally { clearInterval(progressInterval); await refreshBillingSummary(); } }; const takePicture = async () => { if (!cameraRef.current || isAnalyzing) return; await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.7 }); if (photo) { const analysisUri = photo.base64 ? `data:image/jpeg;base64,${photo.base64}` : photo.uri; const galleryUri = photo.uri || analysisUri; setSelectedImage(analysisUri); analyzeImage(analysisUri, galleryUri); } }; const pickImage = async () => { if (isAnalyzing) return; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.7, base64: true, }); if (!result.canceled && result.assets[0]) { const asset = result.assets[0]; const uri = asset.base64 ? `data:image/jpeg;base64,${asset.base64}` : asset.uri; setSelectedImage(uri); analyzeImage(uri, asset.uri || 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); router.back(); } catch (error) { console.error('Saving identified plant failed', error); Alert.alert(billingCopy.genericErrorTitle, billingCopy.genericErrorMessage); } } }; const handleClose = () => { router.back(); }; 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. Grant Permission Cancel ); } return ( {/* Header */} {isHealthMode ? billingCopy.healthTitle : t.scanner} {billingCopy.creditsLabel}: {availableCredits} {/* Camera */} {selectedImage ? ( ) : ( )} {/* Scan Frame */} {selectedImage && ( )} {isAnalyzing && ( <> )} {/* Analyzing Overlay */} {isAnalyzing && ( {analysisProgress < 100 ? t.analyzing : t.result} {Math.round(analysisProgress)}% {t.localProcessing} {analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3} )} {/* 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, }, 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 }, });