Files
digital-pilates/components/medication/MedicationAddOptionsSheet.tsx
richarjiang bcb910140e feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示
- 添加药品有效期字段,支持在添加和编辑药品时设置有效期
- 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入)
- 新增ai-camera和ai-progress两个独立页面处理AI识别流程
- 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件
- 移除本地通知系统,迁移到服务端推送通知
- 添加medicationNotificationCleanup服务清理旧的本地通知
- 更新药品详情页支持AI草稿模式和有效期显示
- 优化药品表单,支持有效期选择和AI识别结果确认
- 更新i18n资源,添加有效期相关翻译

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
2025-11-21 17:32:44 +08:00

410 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});