feat(medications): 增强药品详情页面的编辑功能

- 添加剂量、剂型和服药频率的交互式选择器
- 实现提醒时间的动态编辑和添加功能
- 引入玻璃效果优化删除按钮的视觉体验
- 重构常量配置,提取药物相关常量到独立文件
- 创建可复用的InfoCard组件支持玻璃效果
This commit is contained in:
richarjiang
2025-11-11 11:31:06 +08:00
parent 50525f82a1
commit 7ea558847d
4 changed files with 922 additions and 107 deletions

View File

@@ -1,7 +1,9 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS, TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { getMedicationById, getMedicationRecords } from '@/services/medications'; import { getMedicationById, getMedicationRecords } from '@/services/medications';
@@ -11,10 +13,13 @@ import {
selectMedications, selectMedications,
updateMedicationAction, updateMedicationAction,
} from '@/store/medicationsSlice'; } from '@/store/medicationsSlice';
import type { Medication } from '@/types/medication'; import type { Medication, MedicationForm, RepeatPattern } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice'; import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
@@ -24,6 +29,7 @@ import {
Keyboard, Keyboard,
Modal, Modal,
Platform, Platform,
Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Switch, Switch,
@@ -34,16 +40,6 @@ import {
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
const FORM_LABELS: Record<Medication['form'], string> = {
capsule: '胶囊',
pill: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
syrup: '糖浆',
other: '其他',
};
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png'); const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
type RecordsSummary = { type RecordsSummary = {
@@ -84,6 +80,84 @@ export default function MedicationDetailScreen() {
const [deleteSheetVisible, setDeleteSheetVisible] = useState(false); const [deleteSheetVisible, setDeleteSheetVisible] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
// 剂量选择相关状态
const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
const [dosageValuePicker, setDosageValuePicker] = useState(
medicationFromStore?.dosageValue ?? 1
);
const [dosageUnitPicker, setDosageUnitPicker] = useState(
medicationFromStore?.dosageUnit ?? DOSAGE_UNITS[0]
);
// 剂型选择相关状态
const [formPickerVisible, setFormPickerVisible] = useState(false);
const [formPicker, setFormPicker] = useState<MedicationForm>(
medicationFromStore?.form ?? 'capsule'
);
// 频率选择相关状态
const [frequencyPickerVisible, setFrequencyPickerVisible] = useState(false);
const [repeatPatternPicker, setRepeatPatternPicker] = useState<RepeatPattern>(
medicationFromStore?.repeatPattern ?? 'daily'
);
const [timesPerDayPicker, setTimesPerDayPicker] = useState(
medicationFromStore?.timesPerDay ?? 1
);
// 提醒时间相关状态
const [timePickerVisible, setTimePickerVisible] = useState(false);
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
const [medicationTimesPicker, setMedicationTimesPicker] = useState<string[]>(
medicationFromStore?.medicationTimes ?? []
);
// 辅助函数:从时间字符串创建 Date 对象
const createDateFromTime = useCallback((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 = useCallback((date: Date) => dayjs(date).format('HH:mm'), []);
// 辅助函数:获取默认时间
const getDefaultTimeByIndex = useCallback((index: number) => {
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
}, []);
useEffect(() => { useEffect(() => {
if (!medicationFromStore) { if (!medicationFromStore) {
dispatch(fetchMedications()); dispatch(fetchMedications());
@@ -97,6 +171,37 @@ export default function MedicationDetailScreen() {
} }
}, [medicationFromStore]); }, [medicationFromStore]);
useEffect(() => {
// 同步剂量选择器、剂型选择器、频率选择器和时间选择器的默认值
if (medication) {
setDosageValuePicker(medication.dosageValue);
setDosageUnitPicker(medication.dosageUnit);
setFormPicker(medication.form);
setRepeatPatternPicker(medication.repeatPattern);
setTimesPerDayPicker(medication.timesPerDay);
setMedicationTimesPicker(medication.medicationTimes || []);
}
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form, medication?.repeatPattern, medication?.timesPerDay, medication?.medicationTimes]);
// 根据 timesPerDayPicker 动态调整 medicationTimesPicker与 add-medication.tsx 逻辑一致)
useEffect(() => {
setMedicationTimesPicker((prev) => {
if (timesPerDayPicker > prev.length) {
// 需要添加更多时间
const next = [...prev];
while (next.length < timesPerDayPicker) {
next.push(getDefaultTimeByIndex(next.length));
}
return next;
}
if (timesPerDayPicker < prev.length) {
// 需要删除多余时间
return prev.slice(0, timesPerDayPicker);
}
return prev;
});
}, [timesPerDayPicker, getDefaultTimeByIndex]);
useEffect(() => { useEffect(() => {
setNoteDraft(medication?.note ?? ''); setNoteDraft(medication?.note ?? '');
}, [medication?.note]); }, [medication?.note]);
@@ -422,12 +527,178 @@ export default function MedicationDetailScreen() {
}, [reminderTimes]); }, [reminderTimes]);
const handleDosagePress = useCallback(() => { const handleDosagePress = useCallback(() => {
Alert.alert('每次剂量', `单次服用剂量:${dosageLabel}`); if (!medication) return;
}, [dosageLabel]); setDosagePickerVisible(true);
}, [medication]);
const handleFormPress = useCallback(() => { const handleFormPress = useCallback(() => {
Alert.alert('剂型', `药品剂型:${formLabel}`); if (!medication) return;
}, [formLabel]); setFormPickerVisible(true);
}, [medication]);
const handleFrequencyPress = useCallback(() => {
if (!medication) return;
setFrequencyPickerVisible(true);
}, [medication]);
const confirmDosagePicker = useCallback(async () => {
if (!medication || updatePending) return;
setDosagePickerVisible(false);
// 如果值没有变化,不需要更新
if (dosageValuePicker === medication.dosageValue && dosageUnitPicker === medication.dosageUnit) {
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
dosageValue: dosageValuePicker,
dosageUnit: dosageUnitPicker,
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新剂量失败', err);
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
} finally {
setUpdatePending(false);
}
}, [dispatch, dosageUnitPicker, dosageValuePicker, medication, updatePending]);
const confirmFormPicker = useCallback(async () => {
if (!medication || updatePending) return;
setFormPickerVisible(false);
// 如果值没有变化,不需要更新
if (formPicker === medication.form) {
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
form: formPicker,
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新剂型失败', err);
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
} finally {
setUpdatePending(false);
}
}, [dispatch, formPicker, medication, updatePending]);
const confirmFrequencyPicker = useCallback(async () => {
if (!medication || updatePending) return;
setFrequencyPickerVisible(false);
// 检查频率和时间是否都没有变化
const frequencyChanged = repeatPatternPicker !== medication.repeatPattern || timesPerDayPicker !== medication.timesPerDay;
const timesChanged = JSON.stringify(medicationTimesPicker) !== JSON.stringify(medication.medicationTimes);
if (!frequencyChanged && !timesChanged) {
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
repeatPattern: repeatPatternPicker,
timesPerDay: timesPerDayPicker,
medicationTimes: medicationTimesPicker, // 同时更新提醒时间
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新频率失败', err);
Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。');
} finally {
setUpdatePending(false);
}
}, [dispatch, repeatPatternPicker, timesPerDayPicker, medicationTimesPicker, medication, updatePending]);
// 打开时间选择器
const openTimePicker = useCallback(
(index?: number) => {
try {
if (typeof index === 'number') {
if (index >= 0 && index < medicationTimesPicker.length) {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimesPicker[index]));
} else {
console.error('[MEDICATION] Invalid time index:', index);
return;
}
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimesPicker.length)));
}
setTimePickerVisible(true);
} catch (error) {
console.error('[MEDICATION] Error in openTimePicker:', error);
}
},
[medicationTimesPicker, createDateFromTime, getDefaultTimeByIndex]
);
// 确认时间选择
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);
setMedicationTimesPicker((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, formatTime]
);
// 删除时间
const removeTime = useCallback((index: number) => {
setMedicationTimesPicker((prev) => {
if (prev.length === 1) {
return prev; // 至少保留一个时间
}
return prev.filter((_, idx) => idx !== index);
});
// 同时更新 timesPerDayPicker
setTimesPerDayPicker((prev) => Math.max(1, prev - 1));
}, []);
// 添加时间
const addTime = useCallback(() => {
openTimePicker();
// 同时更新 timesPerDayPicker
setTimesPerDayPicker((prev) => prev + 1);
}, [openTimePicker]);
if (!medicationId) { if (!medicationId) {
return ( return (
@@ -505,7 +776,7 @@ export default function MedicationDetailScreen() {
value={startDateLabel} value={startDateLabel}
icon="calendar-outline" icon="calendar-outline"
colors={colors} colors={colors}
clickable={true} clickable={false}
onPress={handleStartDatePress} onPress={handleStartDatePress}
/> />
<InfoCard <InfoCard
@@ -513,11 +784,15 @@ export default function MedicationDetailScreen() {
value={reminderTimes} value={reminderTimes}
icon="time-outline" icon="time-outline"
colors={colors} colors={colors}
clickable={true} clickable={false}
onPress={handleTimePress} onPress={handleTimePress}
/> />
</View> </View>
<View style={[styles.fullCard, { backgroundColor: colors.surface }]}> <TouchableOpacity
style={[styles.fullCard, { backgroundColor: colors.surface }]}
onPress={handleFrequencyPress}
activeOpacity={0.7}
>
<View style={styles.fullCardLeading}> <View style={styles.fullCardLeading}>
<Ionicons name="repeat-outline" size={18} color={colors.primary} /> <Ionicons name="repeat-outline" size={18} color={colors.primary} />
<Text style={[styles.fullCardLabel, { color: colors.text }]}></Text> <Text style={[styles.fullCardLabel, { color: colors.text }]}></Text>
@@ -526,7 +801,7 @@ export default function MedicationDetailScreen() {
<Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text> <Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} /> <Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View> </View>
</View> </TouchableOpacity>
</Section> </Section>
<Section title="剂量与形式" color={colors.text}> <Section title="剂量与形式" color={colors.text}>
@@ -601,12 +876,25 @@ export default function MedicationDetailScreen() {
]} ]}
> >
<TouchableOpacity <TouchableOpacity
style={styles.deleteButton}
activeOpacity={0.9} activeOpacity={0.9}
onPress={() => setDeleteSheetVisible(true)} onPress={() => setDeleteSheetVisible(true)}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.deleteButton}
glassEffectStyle="regular"
tintColor="rgba(239, 68, 68, 0.8)"
isInteractive={true}
> >
<Ionicons name='trash-outline' size={18} color='#fff' /> <Ionicons name='trash-outline' size={18} color='#fff' />
<Text style={styles.deleteButtonText}></Text> <Text style={styles.deleteButtonText}></Text>
</GlassView>
) : (
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
<Ionicons name='trash-outline' size={18} color='#fff' />
<Text style={styles.deleteButtonText}></Text>
</View>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null} ) : null}
@@ -707,6 +995,303 @@ export default function MedicationDetailScreen() {
</View> </View>
</View> </View>
</Modal> </Modal>
<Modal
visible={dosagePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setDosagePickerVisible(false)}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => setDosagePickerVisible(false)}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={dosageValuePicker}
onValueChange={(value) => setDosageValuePicker(Number(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{DOSAGE_VALUES.map((value) => (
<Picker.Item
key={value}
label={String(value)}
value={value}
/>
))}
</Picker>
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={dosageUnitPicker}
onValueChange={(value) => setDosageUnitPicker(String(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{DOSAGE_UNITS.map((unit) => (
<Picker.Item
key={unit}
label={unit}
value={unit}
/>
))}
</Picker>
</View>
</View>
<View style={styles.pickerActions}>
<Pressable
onPress={() => setDosagePickerVisible(false)}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
</ThemedText>
</Pressable>
<Pressable
onPress={confirmDosagePicker}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
</ThemedText>
</Pressable>
</View>
</View>
</Modal>
<Modal
visible={formPickerVisible}
transparent
animationType="fade"
onRequestClose={() => setFormPickerVisible(false)}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => setFormPickerVisible(false)}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
</ThemedText>
<Picker
selectedValue={formPicker}
onValueChange={(value) => setFormPicker(value as MedicationForm)}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{FORM_OPTIONS.map((option) => (
<Picker.Item
key={option.id}
label={option.label}
value={option.id}
/>
))}
</Picker>
<View style={styles.pickerActions}>
<Pressable
onPress={() => setFormPickerVisible(false)}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
</ThemedText>
</Pressable>
<Pressable
onPress={confirmFormPicker}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
</ThemedText>
</Pressable>
</View>
</View>
</Modal>
<Modal
visible={frequencyPickerVisible}
transparent
animationType="fade"
onRequestClose={() => setFrequencyPickerVisible(false)}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => setFrequencyPickerVisible(false)}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={repeatPatternPicker}
onValueChange={(value) => setRepeatPatternPicker(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={timesPerDayPicker}
onValueChange={(value) => setTimesPerDayPicker(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.timeListSection}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary, marginBottom: 8 }]}>
</ThemedText>
<ScrollView style={styles.timeListScroll} showsVerticalScrollIndicator={false}>
{medicationTimesPicker.map((time, index) => (
<View
key={`${time}-${index}`}
style={[
styles.timeItemInPicker,
{
borderColor: `${colors.border}80`,
backgroundColor: colors.pageBackgroundEmphasis,
},
]}
>
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
<Ionicons name="time" size={16} color={colors.primary} />
<ThemedText style={[styles.timeTextSmall, { color: colors.text }]}>{time}</ThemedText>
</TouchableOpacity>
<Pressable onPress={() => removeTime(index)} disabled={medicationTimesPicker.length === 1} hitSlop={12}>
<Ionicons
name="close-circle"
size={16}
color={medicationTimesPicker.length === 1 ? `${colors.border}80` : colors.textSecondary}
/>
</Pressable>
</View>
))}
<TouchableOpacity
style={[styles.addTimeButtonSmall, { borderColor: colors.primary }]}
onPress={addTime}
>
<Ionicons name="add" size={14} color={colors.primary} />
<ThemedText style={[styles.addTimeLabelSmall, { color: colors.primary }]}></ThemedText>
</TouchableOpacity>
</ScrollView>
</View>
<View style={styles.pickerActions}>
<Pressable
onPress={() => setFrequencyPickerVisible(false)}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
</ThemedText>
</Pressable>
<Pressable
onPress={confirmFrequencyPicker}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
</ThemedText>
</Pressable>
</View>
</View>
</Modal>
{/* 时间选择器 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>
{medication ? ( {medication ? (
<ConfirmationSheet <ConfirmationSheet
visible={deleteSheetVisible} visible={deleteSheetVisible}
@@ -741,42 +1326,6 @@ const Section = ({
); );
}; };
const InfoCard = ({
label,
value,
icon,
colors,
onPress,
clickable = false,
}: {
label: string;
value: string;
icon: keyof typeof Ionicons.glyphMap;
colors: (typeof Colors)[keyof typeof Colors];
onPress?: () => void;
clickable?: boolean;
}) => {
const CardWrapper = clickable ? TouchableOpacity : View;
return (
<CardWrapper
style={[styles.infoCard, { backgroundColor: colors.surface }]}
onPress={onPress}
activeOpacity={clickable ? 0.7 : 1}
>
{clickable && (
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
)}
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</CardWrapper>
);
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@@ -861,43 +1410,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
gap: 12, gap: 12,
}, },
infoCard: {
flex: 1,
borderRadius: 20,
padding: 16,
backgroundColor: '#fff',
gap: 6,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
position: 'relative',
},
infoCardArrow: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 1,
},
infoCardIcon: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#EEF1FF',
alignItems: 'center',
justifyContent: 'center',
},
infoCardLabel: {
fontSize: 13,
color: '#6B7280',
marginTop: 8,
},
infoCardValue: {
fontSize: 16,
fontWeight: '600',
color: '#1F2933',
},
fullCard: { fullCard: {
borderRadius: 22, borderRadius: 22,
padding: 18, padding: 18,
@@ -1073,11 +1585,14 @@ const styles = StyleSheet.create({
deleteButton: { deleteButton: {
height: 56, height: 56,
borderRadius: 24, borderRadius: 24,
backgroundColor: '#EF4444',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 8, gap: 8,
overflow: 'hidden', // 保证玻璃边界圆角效果
},
fallbackDeleteButton: {
backgroundColor: '#EF4444',
shadowColor: 'rgba(239,68,68,0.4)', shadowColor: 'rgba(239,68,68,0.4)',
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1, shadowOpacity: 1,
@@ -1089,4 +1604,109 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: '#fff', color: '#fff',
}, },
// Picker 相关样式
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: 20,
textAlign: 'center',
},
pickerRow: {
flexDirection: 'row',
gap: 16,
marginBottom: 20,
},
pickerColumn: {
flex: 1,
gap: 8,
},
pickerLabel: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
picker: {
width: '100%',
height: 150,
},
pickerItem: {
fontSize: 18,
height: 150,
},
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',
},
// 时间列表相关样式
timeListSection: {
gap: 12,
marginTop: 16,
},
timeListScroll: {
maxHeight: 200,
},
timeItemInPicker: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 10,
marginBottom: 8,
},
timeValue: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
timeTextSmall: {
fontSize: 16,
fontWeight: '600',
},
addTimeButtonSmall: {
borderWidth: 1,
borderRadius: 12,
paddingVertical: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
addTimeLabelSmall: {
fontSize: 13,
fontWeight: '600',
},
}); });

View File

@@ -2,6 +2,7 @@ import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@@ -62,17 +63,6 @@ interface AddMedicationFormData {
note: string; note: string;
} }
const FORM_OPTIONS: Array<{ id: MedicationForm; label: string; icon: keyof typeof MaterialCommunityIcons.glyphMap }> = [
{ id: 'capsule', label: '胶囊', icon: 'pill' },
{ id: 'pill', label: '药片', icon: 'tablet' },
{ id: 'injection', label: '注射', icon: 'needle' },
{ id: 'spray', label: '喷雾', icon: 'spray' },
{ id: 'drop', label: '滴剂', icon: 'eyedropper' },
{ id: 'syrup', label: '糖浆', icon: 'bottle-tonic' },
{ id: 'other', label: '其他', icon: 'dots-horizontal' },
];
const DOSAGE_UNITS = ['片', '粒', '毫升', '滴', '喷', '勺'];
const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1); const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1);
const STEP_TITLES = ['药品名称', '剂型与剂量', '服药频率', '服药时间', '备注']; const STEP_TITLES = ['药品名称', '剂型与剂量', '服药频率', '服药时间', '备注'];
const STEP_DESCRIPTIONS = [ const STEP_DESCRIPTIONS = [

149
components/ui/InfoCard.tsx Normal file
View File

@@ -0,0 +1,149 @@
import type { Colors } from '@/constants/Colors';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export interface InfoCardProps {
label: string;
value: string;
icon: keyof typeof Ionicons.glyphMap;
colors: (typeof Colors)[keyof typeof Colors];
onPress?: () => void;
clickable?: boolean;
glassEffectStyle?: 'clear' | 'regular';
tintColor?: string;
className?: string;
}
export const InfoCard: React.FC<InfoCardProps> = ({
label,
value,
icon,
colors,
onPress,
clickable = false,
glassEffectStyle = 'clear',
}) => {
const isGlassAvailable = isLiquidGlassAvailable();
// 如果可点击且有onPress回调使用TouchableOpacity包装
if (clickable && onPress) {
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={0.7}
>
{isGlassAvailable ? (
<GlassView
style={[
styles.infoCard,
]}
glassEffectStyle={glassEffectStyle}
isInteractive={true}
>
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</GlassView>
) : (
<View style={[styles.infoCard]}>
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</View>
)}
</TouchableOpacity>
);
}
// 不可点击的版本
return (
<View style={styles.container}>
{isGlassAvailable ? (
<GlassView
style={[
styles.infoCard,
{
backgroundColor: 'transparent',
borderColor: `${colors.border}80`,
}
]}
glassEffectStyle={glassEffectStyle}
>
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</GlassView>
) : (
<View style={[styles.infoCard, { backgroundColor: colors.surface }]}>
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
infoCard: {
flex: 1,
borderRadius: 20,
padding: 16,
backgroundColor: '#fff',
gap: 6,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
position: 'relative',
overflow: 'hidden', // 保证玻璃边界圆角效果
},
infoCardArrow: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 1,
},
infoCardIcon: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#EEF1FF',
alignItems: 'center',
justifyContent: 'center',
},
infoCardLabel: {
fontSize: 13,
color: '#6B7280',
marginTop: 8,
},
infoCardValue: {
fontSize: 16,
fontWeight: '600',
color: '#1F2933',
},
});
export default InfoCard;

56
constants/Medication.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* 药物管理相关常量
*/
import type { MedicationForm } from '@/types/medication';
import type { MaterialCommunityIcons } from '@expo/vector-icons';
/**
* 剂型选项配置
*/
export const FORM_OPTIONS: Array<{
id: MedicationForm;
label: string;
icon: keyof typeof MaterialCommunityIcons.glyphMap;
}> = [
{ id: 'capsule', label: '胶囊', icon: 'pill' },
{ id: 'pill', label: '药片', icon: 'tablet' },
{ id: 'injection', label: '注射', icon: 'needle' },
{ id: 'spray', label: '喷雾', icon: 'spray' },
{ id: 'drop', label: '滴剂', icon: 'eyedropper' },
{ id: 'syrup', label: '糖浆', icon: 'bottle-tonic' },
{ id: 'other', label: '其他', icon: 'dots-horizontal' },
];
/**
* 剂型标签映射
*/
export const FORM_LABELS: Record<MedicationForm, string> = {
capsule: '胶囊',
pill: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
syrup: '糖浆',
other: '其他',
};
/**
* 剂量单位选项
*/
export const DOSAGE_UNITS = ['片', '粒', '毫升', '滴', '喷', '勺'];
/**
* 剂量值选项 (0.5 - 10步长0.5)
*/
export const DOSAGE_VALUES = Array.from({ length: 20 }, (_, i) => (i + 1) * 0.5);
/**
* 每日次数选项
*/
export const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1);
/**
* 默认服药时间预设
*/
export const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];