- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
410 lines
10 KiB
TypeScript
410 lines
10 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import { Image } from 'expo-image';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
|
||
type Props = {
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
onManualAdd: () => void;
|
||
onAiRecognize: () => void;
|
||
};
|
||
|
||
export function MedicationAddOptionsSheet({ visible, onClose, onManualAdd, onAiRecognize }: Props) {
|
||
const translateY = useRef(new Animated.Value(300)).current;
|
||
const opacity = useRef(new Animated.Value(0)).current;
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
// 打开时:先显示 Modal,然后执行动画
|
||
setModalVisible(true);
|
||
Animated.parallel([
|
||
Animated.spring(translateY, {
|
||
toValue: 0,
|
||
tension: 65,
|
||
friction: 11,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(opacity, {
|
||
toValue: 1,
|
||
duration: 200,
|
||
useNativeDriver: true,
|
||
}),
|
||
]).start();
|
||
} else if (modalVisible) {
|
||
// 关闭时:先执行动画,动画完成后隐藏 Modal
|
||
Animated.parallel([
|
||
Animated.timing(translateY, {
|
||
toValue: 300,
|
||
duration: 200,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(opacity, {
|
||
toValue: 0,
|
||
duration: 150,
|
||
useNativeDriver: true,
|
||
}),
|
||
]).start(({ finished }) => {
|
||
if (finished) {
|
||
setModalVisible(false);
|
||
}
|
||
});
|
||
}
|
||
}, [visible, modalVisible, opacity, translateY]);
|
||
|
||
const handleClose = () => {
|
||
// 触发关闭动画
|
||
onClose();
|
||
};
|
||
|
||
return (
|
||
<Modal visible={modalVisible} transparent animationType="none" onRequestClose={handleClose}>
|
||
<Pressable style={styles.overlay} onPress={onClose}>
|
||
<Animated.View style={[styles.backdrop, { opacity }]} />
|
||
</Pressable>
|
||
<Animated.View
|
||
style={[
|
||
styles.sheet,
|
||
{
|
||
transform: [{ translateY }],
|
||
},
|
||
]}
|
||
>
|
||
{/* Header */}
|
||
<View style={styles.header}>
|
||
<View style={styles.headerLeft}>
|
||
<Text style={styles.title}>添加药物</Text>
|
||
<Text style={styles.subtitle}>选择录入方式</Text>
|
||
</View>
|
||
<TouchableOpacity onPress={handleClose} style={styles.closeButton} activeOpacity={0.7}>
|
||
<Ionicons name="close" size={24} color="#64748b" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* AI 智能识别 - 主推荐 */}
|
||
<TouchableOpacity activeOpacity={0.95} onPress={onAiRecognize}>
|
||
<LinearGradient
|
||
colors={['#0ea5e9', '#0284c7']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
style={styles.aiCard}
|
||
>
|
||
{/* 推荐标签 */}
|
||
<View style={styles.recommendBadge}>
|
||
<Ionicons name="sparkles" size={14} color="#fbbf24" />
|
||
<Text style={styles.recommendText}>推荐使用</Text>
|
||
</View>
|
||
|
||
<View style={styles.aiContent}>
|
||
<View style={styles.aiLeft}>
|
||
<View style={styles.aiIconWrapper}>
|
||
<Ionicons name="camera" size={32} color="#fff" />
|
||
</View>
|
||
<View style={styles.aiTexts}>
|
||
<Text style={styles.aiTitle}>AI 智能识别</Text>
|
||
<Text style={styles.aiDescription}>
|
||
拍照识别药品信息{'\n'}自动生成提醒计划
|
||
</Text>
|
||
<View style={styles.aiFeatures}>
|
||
<View style={styles.featureItem}>
|
||
<Ionicons name="flash" size={14} color="#fff" />
|
||
<Text style={styles.featureText}>快速识别</Text>
|
||
</View>
|
||
<View style={styles.featureItem}>
|
||
<Ionicons name="checkmark-circle" size={14} color="#fff" />
|
||
<Text style={styles.featureText}>智能填充</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
<Image
|
||
source={require('@/assets/images/medicine/image-medicine.png')}
|
||
style={styles.aiImage}
|
||
contentFit="contain"
|
||
/>
|
||
</View>
|
||
|
||
{/* AI 说明 */}
|
||
<View style={styles.aiFooter}>
|
||
<Ionicons name="information-circle-outline" size={14} color="rgba(255,255,255,0.8)" />
|
||
<Text style={styles.aiFooterText}>需会员或 AI 次数 · 拍摄时确保光线充足</Text>
|
||
</View>
|
||
</LinearGradient>
|
||
</TouchableOpacity>
|
||
|
||
{/* 分隔线 */}
|
||
<View style={styles.divider}>
|
||
<View style={styles.dividerLine} />
|
||
<Text style={styles.dividerText}>或</Text>
|
||
<View style={styles.dividerLine} />
|
||
</View>
|
||
|
||
{/* 手动录入 - 次要选项 */}
|
||
<TouchableOpacity activeOpacity={0.9} onPress={onManualAdd}>
|
||
<View style={styles.manualCard}>
|
||
<View style={styles.manualLeft}>
|
||
<View style={styles.manualIconWrapper}>
|
||
<Ionicons name="create-outline" size={24} color="#6366f1" />
|
||
</View>
|
||
<View style={styles.manualTexts}>
|
||
<Text style={styles.manualTitle}>手动录入</Text>
|
||
<Text style={styles.manualDescription}>
|
||
逐项填写药品信息和服用计划
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.manualRight}>
|
||
<View style={styles.manualBadge}>
|
||
<Text style={styles.manualBadgeText}>免费</Text>
|
||
</View>
|
||
<Ionicons name="chevron-forward" size={20} color="#94a3b8" />
|
||
</View>
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
{/* 底部安全距离 */}
|
||
<View style={styles.safeArea} />
|
||
</Animated.View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
overlay: {
|
||
flex: 1,
|
||
backgroundColor: 'transparent',
|
||
},
|
||
backdrop: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||
},
|
||
sheet: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: '#fff',
|
||
borderTopLeftRadius: 32,
|
||
borderTopRightRadius: 32,
|
||
paddingTop: 24,
|
||
paddingHorizontal: 20,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.15,
|
||
shadowRadius: 20,
|
||
shadowOffset: { width: 0, height: -8 },
|
||
elevation: 12,
|
||
},
|
||
|
||
// Header
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 24,
|
||
},
|
||
headerLeft: {
|
||
flex: 1,
|
||
},
|
||
title: {
|
||
fontSize: 24,
|
||
fontWeight: '700',
|
||
color: '#0f172a',
|
||
marginBottom: 4,
|
||
},
|
||
subtitle: {
|
||
fontSize: 14,
|
||
color: '#64748b',
|
||
fontWeight: '500',
|
||
},
|
||
closeButton: {
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
backgroundColor: '#f1f5f9',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: 12,
|
||
},
|
||
|
||
// AI 卡片 - 主推荐
|
||
aiCard: {
|
||
borderRadius: 24,
|
||
padding: 20,
|
||
marginBottom: 20,
|
||
overflow: 'hidden',
|
||
shadowColor: '#0ea5e9',
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 16,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
elevation: 8,
|
||
},
|
||
recommendBadge: {
|
||
position: 'absolute',
|
||
top: 16,
|
||
right: 16,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 6,
|
||
borderRadius: 16,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.3)',
|
||
},
|
||
recommendText: {
|
||
fontSize: 12,
|
||
fontWeight: '700',
|
||
color: '#fff',
|
||
},
|
||
aiContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 16,
|
||
},
|
||
aiLeft: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
gap: 16,
|
||
},
|
||
aiIconWrapper: {
|
||
width: 56,
|
||
height: 56,
|
||
borderRadius: 16,
|
||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderWidth: 2,
|
||
borderColor: 'rgba(255,255,255,0.3)',
|
||
},
|
||
aiTexts: {
|
||
flex: 1,
|
||
gap: 8,
|
||
},
|
||
aiTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '700',
|
||
color: '#fff',
|
||
},
|
||
aiDescription: {
|
||
fontSize: 14,
|
||
color: 'rgba(255,255,255,0.9)',
|
||
lineHeight: 20,
|
||
},
|
||
aiFeatures: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
marginTop: 4,
|
||
},
|
||
featureItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
},
|
||
featureText: {
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
color: '#fff',
|
||
},
|
||
aiImage: {
|
||
width: 80,
|
||
height: 80,
|
||
marginLeft: 12,
|
||
},
|
||
aiFooter: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
paddingTop: 12,
|
||
borderTopWidth: 1,
|
||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||
},
|
||
aiFooterText: {
|
||
fontSize: 12,
|
||
color: 'rgba(255,255,255,0.8)',
|
||
fontWeight: '500',
|
||
},
|
||
|
||
// 分隔线
|
||
divider: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
},
|
||
dividerLine: {
|
||
flex: 1,
|
||
height: 1,
|
||
backgroundColor: '#e2e8f0',
|
||
},
|
||
dividerText: {
|
||
fontSize: 13,
|
||
color: '#94a3b8',
|
||
fontWeight: '600',
|
||
marginHorizontal: 16,
|
||
},
|
||
|
||
// 手动录入卡片 - 次要选项
|
||
manualCard: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
backgroundColor: '#f8fafc',
|
||
borderRadius: 20,
|
||
padding: 16,
|
||
borderWidth: 1.5,
|
||
borderColor: '#e2e8f0',
|
||
},
|
||
manualLeft: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
},
|
||
manualIconWrapper: {
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 14,
|
||
backgroundColor: '#eef2ff',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
manualTexts: {
|
||
flex: 1,
|
||
gap: 4,
|
||
},
|
||
manualTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
color: '#0f172a',
|
||
},
|
||
manualDescription: {
|
||
fontSize: 13,
|
||
color: '#64748b',
|
||
lineHeight: 18,
|
||
},
|
||
manualRight: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
marginLeft: 12,
|
||
},
|
||
manualBadge: {
|
||
backgroundColor: '#dcfce7',
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
borderRadius: 8,
|
||
},
|
||
manualBadgeText: {
|
||
fontSize: 11,
|
||
fontWeight: '700',
|
||
color: '#16a34a',
|
||
},
|
||
|
||
// 底部安全距离
|
||
safeArea: {
|
||
height: 32,
|
||
},
|
||
});
|