- 移除药品添加选项底部抽屉,直接跳转至AI识别相机 - 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮 - 添加相机引导提示本地存储,避免重复显示 - 修复相机页面布局跳动问题,固定相机高度 - 为医疗免责声明组件添加触觉反馈错误处理 - 实现活动热力图的国际化支持,包括月份标签和统计文本
944 lines
28 KiB
TypeScript
944 lines
28 KiB
TypeScript
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<CameraView>(null);
|
||
const [facing, setFacing] = useState<'back' | 'front'>('back');
|
||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
|
||
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<number>;
|
||
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 (
|
||
<Animated.View style={toggleButtonStyle}>
|
||
<TouchableOpacity
|
||
onPress={onPress}
|
||
disabled={disabled}
|
||
activeOpacity={0.7}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.secondaryBtn}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(255, 255, 255, 0.6)"
|
||
isInteractive={true}
|
||
>
|
||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
// 动画拍摄按钮组件
|
||
const AnimatedCaptureButton = ({
|
||
allRequiredCaptured,
|
||
expandAnimation,
|
||
onCapture,
|
||
onComplete,
|
||
disabled,
|
||
loading,
|
||
}: {
|
||
allRequiredCaptured: boolean;
|
||
expandAnimation: SharedValue<number>;
|
||
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 (
|
||
<Animated.View style={[styles.captureButtonContainer, containerStyle]}>
|
||
{/* 未展开状态:圆形拍摄按钮 */}
|
||
{!allRequiredCaptured && (
|
||
<Animated.View style={[styles.singleCaptureWrapper, singleButtonStyle]}>
|
||
<TouchableOpacity
|
||
onPress={onCapture}
|
||
disabled={disabled}
|
||
activeOpacity={0.8}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.captureBtn}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(255, 255, 255, 0.8)"
|
||
isInteractive={true}
|
||
>
|
||
<View style={styles.captureOuterRing}>
|
||
<View style={styles.captureInner} />
|
||
</View>
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||
<View style={styles.captureOuterRing}>
|
||
<View style={styles.captureInner} />
|
||
</View>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
)}
|
||
|
||
{/* 展开状态:两个分离的按钮 */}
|
||
{allRequiredCaptured && (
|
||
<>
|
||
{/* 左侧:拍照按钮 */}
|
||
<Animated.View style={[styles.splitButtonWrapper, leftButtonStyle]}>
|
||
<TouchableOpacity
|
||
onPress={onCapture}
|
||
disabled={disabled}
|
||
activeOpacity={0.7}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.splitButton}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(14, 165, 233, 0.2)"
|
||
isInteractive={true}
|
||
>
|
||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
|
||
{/* 右侧:完成按钮 */}
|
||
<Animated.View style={[styles.splitButtonWrapper, rightButtonStyle]}>
|
||
<TouchableOpacity
|
||
onPress={onComplete}
|
||
disabled={disabled || loading}
|
||
activeOpacity={0.7}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.splitButton}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(16, 185, 129, 0.2)"
|
||
isInteractive={true}
|
||
>
|
||
{loading ? (
|
||
<ActivityIndicator size="small" color="#10b981" />
|
||
) : (
|
||
<>
|
||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||
</>
|
||
)}
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||
{loading ? (
|
||
<ActivityIndicator size="small" color="#10b981" />
|
||
) : (
|
||
<>
|
||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||
</>
|
||
)}
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</Animated.View>
|
||
</>
|
||
)}
|
||
</Animated.View>
|
||
);
|
||
};
|
||
|
||
if (!permission) {
|
||
return null;
|
||
}
|
||
|
||
if (!permission.granted) {
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
|
||
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 引导说明弹窗 - 移到最外层 */}
|
||
<MedicationPhotoGuideModal
|
||
visible={showGuideModal}
|
||
onClose={() => setShowGuideModal(false)}
|
||
/>
|
||
|
||
<View style={styles.container}>
|
||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||
<HeaderBar
|
||
title="AI 用药识别"
|
||
onBack={() => router.back()}
|
||
transparent
|
||
right={
|
||
<TouchableOpacity
|
||
onPress={() => setShowGuideModal(true)}
|
||
activeOpacity={0.7}
|
||
accessibilityLabel="查看拍摄说明"
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.infoButton}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(255, 255, 255, 0.3)"
|
||
isInteractive={true}
|
||
>
|
||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
}
|
||
/>
|
||
<View style={{ height: insets.top + 40 }} />
|
||
|
||
<View style={styles.topMeta}>
|
||
<View style={styles.metaBadge}>
|
||
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||
</View>
|
||
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||
</View>
|
||
|
||
<View style={styles.cameraCard}>
|
||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||
<LinearGradient
|
||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||
style={styles.cameraOverlay}
|
||
/>
|
||
{coverPreview ? (
|
||
<View style={styles.previewBadge}>
|
||
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.shotsRow}>
|
||
{captureSteps.map((step, index) => {
|
||
const active = step.key === currentStep.key;
|
||
const shot = shots[step.key];
|
||
return (
|
||
<TouchableOpacity
|
||
key={step.key}
|
||
onPress={() => setCurrentStepIndex(index)}
|
||
activeOpacity={0.7}
|
||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||
>
|
||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||
{step.title}
|
||
{!step.mandatory ? '(可选)' : ''}
|
||
</Text>
|
||
{shot ? (
|
||
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
|
||
) : (
|
||
<View style={styles.shotPlaceholder}>
|
||
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
|
||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||
<View style={styles.bottomActions}>
|
||
<TouchableOpacity
|
||
onPress={handlePickFromAlbum}
|
||
disabled={creatingTask || uploading}
|
||
activeOpacity={0.7}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.secondaryBtn}
|
||
glassEffectStyle="clear"
|
||
tintColor="rgba(255, 255, 255, 0.6)"
|
||
isInteractive={true}
|
||
>
|
||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||
</GlassView>
|
||
) : (
|
||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
|
||
<AnimatedCaptureButton
|
||
allRequiredCaptured={allRequiredCaptured}
|
||
expandAnimation={expandAnimation}
|
||
onCapture={handleTakePicture}
|
||
onComplete={handleStartRecognition}
|
||
disabled={creatingTask}
|
||
loading={creatingTask || uploading}
|
||
/>
|
||
|
||
<AnimatedToggleButton
|
||
expandAnimation={expandAnimation}
|
||
onPress={handleToggleCamera}
|
||
disabled={creatingTask}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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',
|
||
},
|
||
});
|