Greenlens/app/scanner.tsx

670 lines
23 KiB
TypeScript

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<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisResult, setAnalysisResult] = useState<IdentificationResult | null>(null);
const cameraRef = useRef<CameraView>(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 (
<ResultCard
result={analysisResult}
imageUri={selectedImage}
onSave={handleSave}
onClose={handleClose}
t={t}
isDark={isDarkMode}
colorPalette={colorPalette}
isGuest={!session}
/>
);
}
// Camera permission
if (!permission?.granted) {
return (
<View style={[styles.permissionContainer, { backgroundColor: colors.surface }]}>
<Ionicons name="camera-outline" size={48} color={colors.text} style={{ marginBottom: 16 }} />
<Text style={[styles.permissionText, { color: colors.text }]}>Camera access is required to scan plants.</Text>
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={[styles.permissionBtnText, { color: colors.onPrimary }]}>Grant Permission</Text>
</TouchableOpacity>
<TouchableOpacity style={{ marginTop: 16 }} onPress={handleClose}>
<Text style={{ color: colors.textMuted }}>Cancel</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.surface }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleClose}>
<Ionicons name="close" size={28} color={colors.iconOnImage} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.iconOnImage }]}>
{isHealthMode ? billingCopy.healthTitle : t.scanner}
</Text>
<View style={[styles.creditBadge, { backgroundColor: colors.heroButton, borderColor: colors.heroButtonBorder }]}>
<Ionicons name="wallet-outline" size={12} color={colors.text} />
<Text style={[styles.creditBadgeText, { color: colors.text }]}>
{billingCopy.creditsLabel}: {availableCredits}
</Text>
</View>
</View>
{/* Camera */}
<View style={styles.cameraContainer}>
{selectedImage ? (
<Image source={{ uri: selectedImage }} style={StyleSheet.absoluteFillObject} blurRadius={4} />
) : (
<CameraView ref={cameraRef} style={StyleSheet.absoluteFillObject} facing="back" />
)}
{/* Scan Frame */}
<View style={[styles.scanFrame, { borderColor: colors.heroButtonBorder }]}>
{selectedImage && (
<Image source={{ uri: selectedImage }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
)}
{isAnalyzing && (
<>
<Animated.View
pointerEvents="none"
style={[
styles.scanPulseFrame,
{
borderColor: colors.heroButton,
transform: [{ scale: scanPulseScale }],
opacity: scanPulseOpacity,
},
]}
/>
<Animated.View
pointerEvents="none"
style={[
styles.scanLine,
{
backgroundColor: colors.heroButton,
transform: [{ translateY: scanLineTranslateY }],
},
]}
/>
</>
)}
<View style={[styles.corner, styles.tl, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.tr, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.bl, { borderColor: colors.iconOnImage }]} />
<View style={[styles.corner, styles.br, { borderColor: colors.iconOnImage }]} />
</View>
</View>
{/* Analyzing Overlay */}
{isAnalyzing && (
<View
style={[
styles.analysisSheet,
{
backgroundColor: colors.overlayStrong,
borderColor: colors.heroButtonBorder,
bottom: analysisBottomOffset,
},
]}
>
<View style={styles.analysisHeader}>
<View style={[styles.analysisBadge, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="sparkles-outline" size={12} color={colors.text} />
<Text style={[styles.analysisLabel, { color: colors.text }]}>
{analysisProgress < 100 ? t.analyzing : t.result}
</Text>
</View>
<Text style={[styles.analysisPercent, { color: colors.textSecondary }]}>
{Math.round(analysisProgress)}%
</Text>
</View>
<View style={[styles.progressBg, { backgroundColor: colors.surfaceMuted }]}>
<View style={[styles.progressFill, { width: `${analysisProgress}%`, backgroundColor: colors.primary }]} />
</View>
<View style={styles.analysisFooter}>
<View style={styles.analysisStatusRow}>
<View style={[styles.statusDot, { backgroundColor: analysisProgress < 100 ? colors.warning : colors.success }]} />
<Text style={[styles.analysisStage, { color: colors.textMuted }]}>{t.localProcessing}</Text>
</View>
<Text style={[styles.analysisStageDetail, { color: colors.textSecondary }]}>
{analysisProgress < 30 ? t.scanStage1 : analysisProgress < 75 ? t.scanStage2 : t.scanStage3}
</Text>
</View>
</View>
)}
{/* Bottom Controls */}
<View
style={[
styles.controls,
{
backgroundColor: colors.background,
paddingBottom: controlsPaddingBottom,
},
]}
>
<TouchableOpacity style={[styles.controlBtn, isAnalyzing && styles.controlBtnDisabled]} onPress={pickImage} disabled={isAnalyzing}>
<Ionicons name="images-outline" size={24} color={colors.textSecondary} />
<Text style={[styles.controlLabel, { color: colors.textMuted }]}>{t.gallery}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.shutterBtn,
{ backgroundColor: colors.primary, borderColor: colors.borderStrong },
isAnalyzing && styles.shutterBtnDisabled,
]}
onPress={takePicture}
disabled={isAnalyzing}
activeOpacity={0.7}
>
<View style={[styles.shutterInner, { backgroundColor: colors.primarySoft }]} />
</TouchableOpacity>
<TouchableOpacity style={[styles.controlBtn, isAnalyzing && styles.controlBtnDisabled]} disabled={isAnalyzing}>
<Ionicons name="help-circle-outline" size={24} color={colors.textMuted} />
<Text style={[styles.controlLabel, { color: colors.textMuted }]}>{t.help}</Text>
</TouchableOpacity>
</View>
</View>
);
}
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 },
});