feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机 - 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮 - 添加相机引导提示本地存储,避免重复显示 - 修复相机页面布局跳动问题,固定相机高度 - 为医疗免责声明组件添加触觉反馈错误处理 - 实现活动热力图的国际化支持,包括月份标签和统计文本
This commit is contained in:
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -16,14 +17,27 @@ 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 },
|
||||
@@ -53,21 +67,73 @@ export default function MedicationAiCameraScreen() {
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
|
||||
// 动画控制:0 = 圆形拍摄按钮,1 = 展开为两个按钮
|
||||
const expandAnimation = useSharedValue(0);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage
|
||||
if (!hasSeenGuide) {
|
||||
setShowGuideModal(true);
|
||||
}
|
||||
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'));
|
||||
};
|
||||
@@ -167,6 +233,267 @@ export default function MedicationAiCameraScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 动画翻转按钮组件
|
||||
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;
|
||||
}
|
||||
@@ -234,7 +561,7 @@ export default function MedicationAiCameraScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={styles.cameraFrame}>
|
||||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||
@@ -300,72 +627,21 @@ export default function MedicationAiCameraScreen() {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleTakePicture}
|
||||
<AnimatedCaptureButton
|
||||
allRequiredCaptured={allRequiredCaptured}
|
||||
expandAnimation={expandAnimation}
|
||||
onCapture={handleTakePicture}
|
||||
onComplete={handleStartRecognition}
|
||||
disabled={creatingTask}
|
||||
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>
|
||||
loading={creatingTask || uploading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
<AnimatedToggleButton
|
||||
expandAnimation={expandAnimation}
|
||||
onPress={handleToggleCamera}
|
||||
disabled={creatingTask}
|
||||
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>
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 只要正面和背面都有照片就显示识别按钮 */}
|
||||
{allRequiredCaptured && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleStartRecognition}
|
||||
disabled={creatingTask || uploading}
|
||||
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
{creatingTask || uploading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} />
|
||||
) : (
|
||||
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
|
||||
开始识别
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
@@ -496,41 +772,82 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
height: 64,
|
||||
},
|
||||
singleCaptureWrapper: {
|
||||
position: 'absolute',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 86,
|
||||
height: 86,
|
||||
borderRadius: 43,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 3,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 76,
|
||||
height: 76,
|
||||
borderRadius: 38,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user