Files
digital-pilates/app/medications/edit-frequency.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

672 lines
20 KiB
TypeScript

import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { updateMedicationAction } from '@/store/medicationsSlice';
import type { RepeatPattern } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
// 辅助函数:从时间字符串创建 Date 对象
const createDateFromTime = (time: string) => {
try {
if (!time || typeof time !== 'string') {
console.warn('[MEDICATION] Invalid time string provided:', time);
return new Date();
}
const parts = time.split(':');
if (parts.length !== 2) {
console.warn('[MEDICATION] Invalid time format:', time);
return new Date();
}
const hour = parseInt(parts[0], 10);
const minute = parseInt(parts[1], 10);
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
console.warn('[MEDICATION] Invalid time values:', { hour, minute });
return new Date();
}
const next = new Date();
next.setHours(hour, minute, 0, 0);
if (isNaN(next.getTime())) {
console.error('[MEDICATION] Failed to create valid date');
return new Date();
}
return next;
} catch (error) {
console.error('[MEDICATION] Error in createDateFromTime:', error);
return new Date();
}
};
// 辅助函数:格式化时间
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
// 辅助函数:获取默认时间
const getDefaultTimeByIndex = (index: number) => {
return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
};
export default function EditMedicationFrequencyScreen() {
const params = useLocalSearchParams<{
medicationId?: string;
medicationName?: string;
repeatPattern?: RepeatPattern;
timesPerDay?: string;
medicationTimes?: string;
}>();
const medicationId = Array.isArray(params.medicationId) ? params.medicationId[0] : params.medicationId;
const medicationName = Array.isArray(params.medicationName) ? params.medicationName[0] : params.medicationName;
const initialRepeatPattern = (Array.isArray(params.repeatPattern) ? params.repeatPattern[0] : params.repeatPattern) as RepeatPattern || 'daily';
const initialTimesPerDay = parseInt(Array.isArray(params.timesPerDay) ? params.timesPerDay[0] : params.timesPerDay || '1', 10);
const initialTimes = params.medicationTimes
? (Array.isArray(params.medicationTimes) ? params.medicationTimes[0] : params.medicationTimes).split(',')
: ['08:00'];
const dispatch = useAppDispatch();
const router = useRouter();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const insets = useSafeAreaInsets();
const [repeatPattern, setRepeatPattern] = useState<RepeatPattern>(initialRepeatPattern);
const [timesPerDay, setTimesPerDay] = useState(initialTimesPerDay);
const [medicationTimes, setMedicationTimes] = useState<string[]>(initialTimes);
const [saving, setSaving] = useState(false);
// 时间选择器相关状态
const [timePickerVisible, setTimePickerVisible] = useState(false);
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
// 根据 timesPerDay 动态调整 medicationTimes
useEffect(() => {
setMedicationTimes((prev) => {
if (timesPerDay > prev.length) {
const next = [...prev];
while (next.length < timesPerDay) {
next.push(getDefaultTimeByIndex(next.length));
}
return next;
}
if (timesPerDay < prev.length) {
return prev.slice(0, timesPerDay);
}
return prev;
});
}, [timesPerDay]);
// 打开时间选择器
const openTimePicker = useCallback(
(index?: number) => {
try {
if (typeof index === 'number') {
if (index >= 0 && index < medicationTimes.length) {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimes[index]));
} else {
console.error('[MEDICATION] Invalid time index:', index);
return;
}
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
}
setTimePickerVisible(true);
} catch (error) {
console.error('[MEDICATION] Error in openTimePicker:', error);
}
},
[medicationTimes]
);
// 确认时间选择
const confirmTime = useCallback(
(date: Date) => {
try {
if (!date || isNaN(date.getTime())) {
console.error('[MEDICATION] Invalid date provided to confirmTime');
setTimePickerVisible(false);
setEditingTimeIndex(null);
return;
}
const nextValue = formatTime(date);
setMedicationTimes((prev) => {
if (editingTimeIndex == null) {
return [...prev, nextValue];
}
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
});
setTimePickerVisible(false);
setEditingTimeIndex(null);
} catch (error) {
console.error('[MEDICATION] Error in confirmTime:', error);
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
},
[editingTimeIndex]
);
// 删除时间
const removeTime = useCallback((index: number) => {
setMedicationTimes((prev) => {
if (prev.length === 1) {
return prev; // 至少保留一个时间
}
return prev.filter((_, idx) => idx !== index);
});
// 同时更新 timesPerDay
setTimesPerDay((prev) => Math.max(1, prev - 1));
}, []);
// 添加时间
const addTime = useCallback(() => {
openTimePicker();
// 同时更新 timesPerDay
setTimesPerDay((prev) => prev + 1);
}, [openTimePicker]);
// 保存修改
const handleSave = useCallback(async () => {
if (!medicationId || saving) return;
setSaving(true);
try {
const updated = await dispatch(
updateMedicationAction({
id: medicationId,
repeatPattern,
timesPerDay,
medicationTimes,
})
).unwrap();
router.back();
} catch (err) {
console.error('更新频率失败', err);
Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。');
} finally {
setSaving(false);
}
}, [dispatch, medicationId, medicationTimes, repeatPattern, router, saving, timesPerDay]);
const frequencyLabel = useMemo(() => {
switch (repeatPattern) {
case 'daily':
return `每日 ${timesPerDay}`;
case 'weekly':
return `每周 ${timesPerDay}`;
default:
return `自定义 · ${timesPerDay} 次/日`;
}
}, [repeatPattern, timesPerDay]);
if (!medicationId) {
return (
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
<HeaderBar title="编辑服药频率" variant="minimal" />
<View style={styles.centered}>
<ThemedText style={styles.emptyText}></ThemedText>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
<HeaderBar
title="编辑服药频率"
variant="minimal"
transparent
/>
<ScrollView
contentContainerStyle={[
styles.content,
{
paddingTop: insets.top + 72,
paddingBottom: Math.max(insets.bottom, 16) + 120,
},
]}
showsVerticalScrollIndicator={false}
>
{/* 药品名称提示 */}
{medicationName && (
<View style={[styles.medicationNameCard, { backgroundColor: colors.surface }]}>
<Ionicons name="medical" size={20} color={colors.primary} />
<ThemedText style={[styles.medicationNameText, { color: colors.text }]}>
{medicationName}
</ThemedText>
</View>
)}
{/* 频率选择 */}
<View style={styles.section}>
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
</ThemedText>
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={repeatPattern}
onValueChange={(value) => setRepeatPattern(value as RepeatPattern)}
itemStyle={styles.pickerItem}
style={styles.picker}
>
<Picker.Item label="每日" value="daily" />
{/* <Picker.Item label="每周" value="weekly" />
<Picker.Item label="自定义" value="custom" /> */}
</Picker>
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={timesPerDay}
onValueChange={(value) => setTimesPerDay(Number(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{TIMES_PER_DAY_OPTIONS.map((times) => (
<Picker.Item
key={times}
label={`${times}`}
value={times}
/>
))}
</Picker>
</View>
</View>
<View style={[styles.frequencySummary, { backgroundColor: colors.surface }]}>
<Ionicons name="repeat" size={18} color={colors.primary} />
<ThemedText style={[styles.frequencySummaryText, { color: colors.text }]}>
{frequencyLabel}
</ThemedText>
</View>
</View>
{/* 提醒时间列表 */}
<View style={styles.section}>
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
</ThemedText>
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
</ThemedText>
<View style={styles.timeList}>
{medicationTimes.map((time, index) => (
<View
key={`${time}-${index}`}
style={[
styles.timeItem,
{
borderColor: `${colors.border}80`,
backgroundColor: colors.surface,
},
]}
>
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
<Ionicons name="time" size={20} color={colors.primary} />
<ThemedText style={[styles.timeText, { color: colors.text }]}>{time}</ThemedText>
</TouchableOpacity>
<Pressable
onPress={() => removeTime(index)}
disabled={medicationTimes.length === 1}
hitSlop={12}
>
<Ionicons
name="close-circle"
size={20}
color={medicationTimes.length === 1 ? `${colors.border}80` : colors.textSecondary}
/>
</Pressable>
</View>
))}
<TouchableOpacity
style={[styles.addTimeButton, { borderColor: colors.primary }]}
onPress={addTime}
>
<Ionicons name="add" size={18} color={colors.primary} />
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}>
</ThemedText>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* 底部保存按钮 */}
<View
style={[
styles.footerBar,
{
paddingBottom: Math.max(insets.bottom, 18),
backgroundColor: colors.pageBackgroundEmphasis,
},
]}
>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleSave}
disabled={saving}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.saveButton}
glassEffectStyle="regular"
tintColor={`rgba(122, 90, 248, 0.8)`}
isInteractive={!saving}
>
{saving ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<ThemedText style={styles.saveButtonText}></ThemedText>
</>
)}
</GlassView>
) : (
<View style={[styles.saveButton, styles.fallbackSaveButton]}>
{saving ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<ThemedText style={styles.saveButtonText}></ThemedText>
</>
)}
</View>
)}
</TouchableOpacity>
</View>
{/* 时间选择器 Modal */}
<Modal
visible={timePickerVisible}
transparent
animationType="fade"
onRequestClose={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
</ThemedText>
<DateTimePicker
value={timePickerDate}
mode="time"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setTimePickerDate(date);
} else {
if (event.type === 'set' && date) {
confirmTime(date);
} else {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.pickerActions}>
<Pressable
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
</ThemedText>
</Pressable>
<Pressable
onPress={() => confirmTime(timePickerDate)}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
</ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingHorizontal: 20,
gap: 32,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 16,
textAlign: 'center',
},
medicationNameCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderRadius: 20,
paddingHorizontal: 18,
paddingVertical: 14,
},
medicationNameText: {
fontSize: 16,
fontWeight: '600',
},
section: {
gap: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
},
sectionDescription: {
fontSize: 14,
lineHeight: 20,
},
pickerRow: {
flexDirection: 'row',
gap: 16,
},
pickerColumn: {
flex: 1,
gap: 8,
},
pickerLabel: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
picker: {
width: '100%',
height: 150,
},
pickerItem: {
fontSize: 18,
height: 150,
},
frequencySummary: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
frequencySummaryText: {
fontSize: 16,
fontWeight: '600',
},
timeList: {
gap: 12,
},
timeItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
timeValue: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
timeText: {
fontSize: 18,
fontWeight: '600',
},
addTimeButton: {
borderWidth: 1,
borderRadius: 16,
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
addTimeLabel: {
fontSize: 15,
fontWeight: '600',
},
footerBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 20,
paddingTop: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(15,23,42,0.06)',
},
saveButton: {
height: 56,
borderRadius: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
overflow: 'hidden',
},
fallbackSaveButton: {
backgroundColor: '#7a5af8',
shadowColor: 'rgba(122, 90, 248, 0.4)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
saveButtonText: {
fontSize: 17,
fontWeight: '700',
color: '#fff',
},
pickerBackdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
pickerSheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
pickerTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
textAlign: 'center',
},
pickerActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
pickerBtn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
pickerBtnPrimary: {
borderWidth: 0,
},
pickerBtnText: {
fontSize: 16,
fontWeight: '600',
},
});