670 lines
23 KiB
TypeScript
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 },
|
|
});
|