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 { 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, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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); // 首次进入时显示引导弹窗 useEffect(() => { const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage if (!hasSeenGuide) { setShowGuideModal(true); } }, []); const currentStep = captureSteps[currentStepIndex]; const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri; const allRequiredCaptured = Boolean(shots.front && shots.side); const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); 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); } }; 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() ? ( 从相册 ) : ( 从相册 )} {isLiquidGlassAvailable() ? ( ) : ( )} {isLiquidGlassAvailable() ? ( 翻转 ) : ( 翻转 )} {/* 只要正面和背面都有照片就显示识别按钮 */} {allRequiredCaptured && ( {creatingTask || uploading ? ( ) : ( 开始识别 )} )} ); } 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', }, captureBtn: { width: 86, height: 86, borderRadius: 43, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', shadowColor: '#0ea5e9', shadowOpacity: 0.25, shadowRadius: 16, shadowOffset: { width: 0, height: 8 }, }, fallbackCaptureBtn: { backgroundColor: 'rgba(255, 255, 255, 0.95)', borderWidth: 3, borderColor: 'rgba(14, 165, 233, 0.2)', }, captureOuterRing: { width: 76, height: 76, borderRadius: 38, backgroundColor: 'rgba(255, 255, 255, 0.15)', justifyContent: 'center', alignItems: 'center', }, captureInner: { width: 60, height: 60, borderRadius: 30, backgroundColor: '#fff', shadowColor: '#0ea5e9', shadowOpacity: 0.4, shadowRadius: 8, shadowOffset: { width: 0, height: 2 }, }, 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', }, });