import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal'; import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useCosUpload } from '@/hooks/useCosUpload'; import { createMedicationRecognitionTask } from '@/services/medications'; import { getItem, setItem } from '@/utils/kvStore'; import { Ionicons } from '@expo/vector-icons'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { Easing, Extrapolation, interpolate, SharedValue, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; // 本地存储的 key,用于记录用户是否已经看过拍摄引导 const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen'; const captureSteps = [ { key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true }, { key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true }, { key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false }, ] as const; type CaptureKey = (typeof captureSteps)[number]['key']; type Shot = { uri: string; }; export default function MedicationAiCameraScreen() { const insets = useSafeAreaInsets(); const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; const colors = Colors[scheme]; const { ensureLoggedIn } = useAuthGuard(); const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' }); const [permission, requestPermission] = useCameraPermissions(); const cameraRef = useRef(null); const [facing, setFacing] = useState<'back' | 'front'>('back'); const [currentStepIndex, setCurrentStepIndex] = useState(0); const [shots, setShots] = useState>({ front: null, side: null, aux: null, }); const [creatingTask, setCreatingTask] = useState(false); const [showGuideModal, setShowGuideModal] = useState(false); // 动画控制:0 = 圆形拍摄按钮,1 = 展开为两个按钮 const expandAnimation = useSharedValue(0); // 首次进入时显示引导弹窗 useEffect(() => { const checkAndShowGuide = async () => { try { // 从本地存储读取是否已经看过引导 const hasSeenGuide = await getItem(MEDICATION_GUIDE_SEEN_KEY); // 如果没有看过(返回 null 或 undefined),则显示引导弹窗 if (!hasSeenGuide) { setShowGuideModal(true); // 标记为已看过,下次进入不再自动显示 await setItem(MEDICATION_GUIDE_SEEN_KEY, 'true'); } } catch (error) { console.error('[MEDICATION_AI] 检查引导状态失败', error); // 出错时为了更好的用户体验,还是显示引导 setShowGuideModal(true); } }; checkAndShowGuide(); }, []); const currentStep = captureSteps[currentStepIndex]; const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri; const allRequiredCaptured = Boolean(shots.front && shots.side); // 当必需照片都拍摄完成后,触发展开动画 useEffect(() => { if (allRequiredCaptured) { expandAnimation.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.cubic), }); } else { expandAnimation.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.cubic), }); } }, [allRequiredCaptured]); const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); // 计算固定的相机高度,不受按钮状态影响,避免布局跳动 const cameraHeight = useMemo(() => { const { height: screenHeight } = Dimensions.get('window'); // 计算固定占用的高度(使用最大值确保布局稳定) const headerHeight = insets.top + 40; // HeaderBar 高度 const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域:padding + badge + title + subtitle + gap const shotsRowHeight = 12 + 88; // shotsRow 区域:paddingTop + shotCard 高度 // 固定使用展开状态的高度,确保布局不会跳动 const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域(不包含动态变化部分) const margins = 12 + 12; // cameraCard 的上下边距 // 可用于相机的高度 = 屏幕高度 - 所有固定元素高度 const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins; // 确保最小高度为 300,最大不超过屏幕的 50% return Math.max(300, Math.min(availableHeight, screenHeight * 0.5)); }, [insets.top, insets.bottom]); const handleToggleCamera = () => { setFacing((prev) => (prev === 'back' ? 'front' : 'back')); }; const handlePickFromAlbum = async () => { try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 0.9, }); if (!result.canceled && result.assets?.length) { const asset = result.assets[0]; setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } })); // 拍摄完成后自动进入下一步(如果还有下一步) if (currentStepIndex < captureSteps.length - 1) { setTimeout(() => { goNextStep(); }, 300); } } } catch (error) { console.error('[MEDICATION_AI] pick image failed', error); Alert.alert('选择失败', '请重试或更换图片'); } }; const handleTakePicture = async () => { if (!cameraRef.current) return; try { const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 }); if (photo?.uri) { setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } })); // 拍摄完成后自动进入下一步(如果还有下一步) if (currentStepIndex < captureSteps.length - 1) { setTimeout(() => { goNextStep(); }, 300); } } } catch (error) { console.error('[MEDICATION_AI] take picture failed', error); Alert.alert('拍摄失败', '请重试'); } }; const goNextStep = () => { if (currentStepIndex < captureSteps.length - 1) { setCurrentStepIndex((prev) => prev + 1); } }; const handleStartRecognition = async () => { // 检查必需照片是否完成 if (!allRequiredCaptured) { Alert.alert('照片不足', '请至少完成正面和背面拍摄'); return; } await startRecognition(); }; const startRecognition = async () => { if (!shots.front || !shots.side) return; const isLoggedIn = await ensureLoggedIn(); if (!isLoggedIn) return; try { setCreatingTask(true); const [frontUpload, sideUpload, auxUpload] = await Promise.all([ upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }), upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }), shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null), ]); const task = await createMedicationRecognitionTask({ frontImageUrl: frontUpload.url, sideImageUrl: sideUpload.url, auxiliaryImageUrl: auxUpload?.url, }); router.replace({ pathname: '/medications/ai-progress', params: { taskId: task.taskId, cover: frontUpload.url, }, }); } catch (error: any) { console.error('[MEDICATION_AI] recognize failed', error); Alert.alert('创建任务失败', error?.message || '请检查网络后重试'); } finally { setCreatingTask(false); } }; // 动画翻转按钮组件 const AnimatedToggleButton = ({ expandAnimation, onPress, disabled, }: { expandAnimation: SharedValue; onPress: () => void; disabled: boolean; }) => { // 翻转按钮的位置动画 - 展开时向右移出 const toggleButtonStyle = useAnimatedStyle(() => { const translateX = interpolate( expandAnimation.value, [0, 1], [0, 100], // 向右移出屏幕 Extrapolation.CLAMP ); const opacity = interpolate( expandAnimation.value, [0, 0.3], [1, 0], Extrapolation.CLAMP ); return { opacity, transform: [{ translateX }], }; }); return ( {isLiquidGlassAvailable() ? ( 翻转 ) : ( 翻转 )} ); }; // 动画拍摄按钮组件 const AnimatedCaptureButton = ({ allRequiredCaptured, expandAnimation, onCapture, onComplete, disabled, loading, }: { allRequiredCaptured: boolean; expandAnimation: SharedValue; onCapture: () => void; onComplete: () => void; disabled: boolean; loading: boolean; }) => { // 单个拍摄按钮的缩放和透明度动画 const singleButtonStyle = useAnimatedStyle(() => ({ opacity: interpolate( expandAnimation.value, [0, 0.3], [1, 0], Extrapolation.CLAMP ), transform: [{ scale: interpolate( expandAnimation.value, [0, 0.3], [1, 0.8], Extrapolation.CLAMP ) }], })); // 左侧按钮的位置和透明度动画 const leftButtonStyle = useAnimatedStyle(() => { const translateX = interpolate( expandAnimation.value, [0, 1], [0, -70], // 向左移动更多距离 Extrapolation.CLAMP ); const opacity = interpolate( expandAnimation.value, [0.4, 1], [0, 1], Extrapolation.CLAMP ); const scale = interpolate( expandAnimation.value, [0.4, 1], [0.8, 1], Extrapolation.CLAMP ); return { opacity, transform: [{ translateX }, { scale }], }; }); // 右侧按钮的位置和透明度动画 const rightButtonStyle = useAnimatedStyle(() => { const translateX = interpolate( expandAnimation.value, [0, 1], [0, 70], // 向右移动更多距离 Extrapolation.CLAMP ); const opacity = interpolate( expandAnimation.value, [0.4, 1], [0, 1], Extrapolation.CLAMP ); const scale = interpolate( expandAnimation.value, [0.4, 1], [0.8, 1], Extrapolation.CLAMP ); return { opacity, transform: [{ translateX }, { scale }], }; }); // 容器整体向右平移的动画 const containerStyle = useAnimatedStyle(() => { const translateX = interpolate( expandAnimation.value, [0, 1], [0, 60], // 整体向右移动更多,与相册按钮保持距离 Extrapolation.CLAMP ); return { transform: [{ translateX }], }; }); return ( {/* 未展开状态:圆形拍摄按钮 */} {!allRequiredCaptured && ( {isLiquidGlassAvailable() ? ( ) : ( )} )} {/* 展开状态:两个分离的按钮 */} {allRequiredCaptured && ( <> {/* 左侧:拍照按钮 */} {isLiquidGlassAvailable() ? ( 拍照 ) : ( 拍照 )} {/* 右侧:完成按钮 */} {isLiquidGlassAvailable() ? ( {loading ? ( ) : ( <> 完成 )} ) : ( {loading ? ( ) : ( <> 完成 )} )} )} ); }; if (!permission) { return null; } if (!permission.granted) { return ( router.back()} transparent /> 需要相机权限 授权后即可快速拍摄药品包装,自动识别信息 授权访问相机 ); } return ( <> {/* 引导说明弹窗 - 移到最外层 */} setShowGuideModal(false)} /> router.back()} transparent right={ setShowGuideModal(true)} activeOpacity={0.7} accessibilityLabel="查看拍摄说明" > {isLiquidGlassAvailable() ? ( ) : ( )} } /> {stepTitle} {currentStep.title} {currentStep.subtitle} {coverPreview ? ( ) : null} {captureSteps.map((step, index) => { const active = step.key === currentStep.key; const shot = shots[step.key]; return ( setCurrentStepIndex(index)} activeOpacity={0.7} style={[styles.shotCard, active && styles.shotCardActive]} > {step.title} {!step.mandatory ? '(可选)' : ''} {shot ? ( ) : ( 未拍摄 )} ); })} {isLiquidGlassAvailable() ? ( 从相册 ) : ( 从相册 )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, topMeta: { paddingHorizontal: 20, paddingTop: 12, gap: 6, }, metaBadge: { alignSelf: 'flex-start', backgroundColor: '#e0f2fe', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 14, }, metaBadgeText: { color: '#0369a1', fontWeight: '700', fontSize: 12, }, metaTitle: { fontSize: 22, fontWeight: '700', color: '#0f172a', }, metaSubtitle: { fontSize: 14, color: '#475569', }, cameraCard: { marginHorizontal: 20, marginTop: 12, borderRadius: 24, overflow: 'hidden', shadowColor: '#0f172a', shadowOpacity: 0.12, shadowRadius: 18, shadowOffset: { width: 0, height: 10 }, }, cameraFrame: { borderRadius: 24, overflow: 'hidden', backgroundColor: '#0b172a', height: 360, }, cameraView: { flex: 1, }, cameraOverlay: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 80, }, previewBadge: { position: 'absolute', right: 12, bottom: 12, width: 90, height: 90, borderRadius: 12, overflow: 'hidden', borderWidth: 2, borderColor: '#fff', }, previewImage: { width: '100%', height: '100%', }, shotsRow: { flexDirection: 'row', paddingHorizontal: 20, paddingTop: 12, gap: 10, }, shotCard: { flex: 1, borderRadius: 14, backgroundColor: '#f8fafc', padding: 10, gap: 8, borderWidth: 1, borderColor: '#e2e8f0', }, shotCardActive: { borderColor: '#38bdf8', backgroundColor: '#ecfeff', }, shotLabel: { fontSize: 12, color: '#475569', fontWeight: '600', }, shotLabelActive: { color: '#0ea5e9', }, shotThumb: { width: '100%', height: 70, borderRadius: 12, }, shotPlaceholder: { height: 70, borderRadius: 12, backgroundColor: '#e2e8f0', alignItems: 'center', justifyContent: 'center', }, shotPlaceholderText: { color: '#94a3b8', fontSize: 12, }, bottomBar: { paddingHorizontal: 20, paddingTop: 12, gap: 10, }, bottomActions: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, captureButtonContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, height: 64, }, singleCaptureWrapper: { position: 'absolute', }, captureBtn: { width: 64, height: 64, borderRadius: 32, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', shadowColor: '#0ea5e9', shadowOpacity: 0.25, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, }, fallbackCaptureBtn: { backgroundColor: 'rgba(255, 255, 255, 0.95)', borderWidth: 2, borderColor: 'rgba(14, 165, 233, 0.2)', }, captureOuterRing: { width: 56, height: 56, borderRadius: 28, backgroundColor: 'rgba(255, 255, 255, 0.15)', justifyContent: 'center', alignItems: 'center', }, captureInner: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#fff', shadowColor: '#0ea5e9', shadowOpacity: 0.4, shadowRadius: 6, shadowOffset: { width: 0, height: 2 }, }, splitButtonWrapper: { position: 'absolute', alignItems: 'center', justifyContent: 'center', }, splitButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingHorizontal: 20, paddingVertical: 11, borderRadius: 16, overflow: 'hidden', width: 110, height: 48, shadowColor: '#0f172a', shadowOpacity: 0.1, shadowRadius: 10, shadowOffset: { width: 0, height: 4 }, }, fallbackSplitButton: { backgroundColor: 'rgba(255, 255, 255, 0.95)', borderWidth: 1, borderColor: 'rgba(15, 23, 42, 0.1)', }, splitButtonLabel: { fontSize: 13, fontWeight: '600', color: '#0f172a', }, secondaryBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 16, paddingVertical: 12, borderRadius: 16, overflow: 'hidden', shadowColor: '#0f172a', shadowOpacity: 0.08, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, }, fallbackSecondaryBtn: { backgroundColor: 'rgba(255, 255, 255, 0.9)', borderWidth: 1, borderColor: 'rgba(15, 23, 42, 0.1)', }, secondaryBtnText: { color: '#0f172a', fontWeight: '600', fontSize: 14, }, primaryCta: { marginTop: 6, borderRadius: 16, paddingVertical: 14, alignItems: 'center', shadowColor: '#0f172a', shadowOpacity: 0.12, shadowRadius: 10, shadowOffset: { width: 0, height: 6 }, }, primaryText: { fontSize: 16, fontWeight: '700', }, skipBtn: { alignSelf: 'center', paddingVertical: 6, paddingHorizontal: 12, }, skipText: { color: '#475569', fontSize: 13, }, infoButton: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, fallbackInfoButton: { backgroundColor: 'rgba(255, 255, 255, 0.9)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.3)', }, permissionCard: { marginHorizontal: 24, borderRadius: 18, padding: 20, backgroundColor: '#fff', shadowColor: '#0f172a', shadowOpacity: 0.08, shadowRadius: 12, shadowOffset: { width: 0, height: 10 }, alignItems: 'center', gap: 10, }, permissionTitle: { fontSize: 18, fontWeight: '700', color: '#0f172a', }, permissionTip: { fontSize: 14, color: '#475569', textAlign: 'center', lineHeight: 20, }, permissionBtn: { marginTop: 6, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 12, }, permissionBtnText: { color: '#fff', fontWeight: '700', }, });