Files
digital-pilates/app/medications/add-medication.tsx
richarjiang 2ed3562a00 feat(ui): 优化药品管理页面的视觉设计和背景效果
- 调整药品列表页面的渐变背景颜色,移除最后一个颜色值
- 修复药品详情页面的样式数组格式问题
- 为添加药品页面添加渐变背景和装饰性圆圈元素
- 优化表单按钮的间距和样式,提升视觉层次感
- 统一背景颜色处理,增强页面一致性
2025-11-11 17:57:36 +08:00

1608 lines
50 KiB
TypeScript
Raw 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 { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
import type { MedicationForm, RepeatPattern } from '@/types/medication';
import { Ionicons, MaterialCommunityIcons } 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 dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type ThemeColors = typeof Colors.light | typeof Colors.dark;
const HEX_COLOR_REGEX = /^#([0-9a-f]{6})$/i;
const withAlpha = (hex: string, alpha: number) => {
if (!HEX_COLOR_REGEX.test(hex)) {
return hex;
}
const normalized = hex.replace('#', '');
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
const safeAlpha = Math.min(Math.max(alpha, 0), 1);
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
};
// 表单数据接口(用于内部状态管理)
interface AddMedicationFormData {
name: string;
photoUrl?: string | null;
form: MedicationForm;
dosageValue: string;
dosageUnit: string;
timesPerDay: number;
medicationTimes: string[];
startDate: string;
note: string;
}
const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1);
const STEP_TITLES = ['药品名称', '剂型与剂量', '服药频率', '服药时间', '备注'];
const STEP_DESCRIPTIONS = [
'为药物命名并上传包装照片,方便识别',
'选择药片类型并填写每次的用药剂量',
'设置用药频率以及每日次数',
'添加并管理每天的提醒时间',
'填写备注或医生叮嘱(可选)',
];
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
const getDefaultTimeByIndex = (index: number) => DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
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();
}
};
export default function AddMedicationScreen() {
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme];
const glassAvailable = isLiquidGlassAvailable();
const totalSteps = STEP_TITLES.length;
const [currentStep, setCurrentStep] = useState(0);
const [medicationName, setMedicationName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { upload, uploading } = useCosUpload({ prefix: 'images/medications' });
// 获取登录验证相关的功能
const { ensureLoggedIn } = useAuthGuard();
const softBorderColor = useMemo(() => withAlpha(colors.border, 0.45), [colors.border]);
const fadedBorderFill = useMemo(() => withAlpha('#ffffff', 1), [colors.border]);
const glassPrimaryTint = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.55 : 0.45), [colors.primary, theme]);
const glassDisabledTint = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.45 : 0.6), [colors.border, theme]);
const glassPrimaryBackground = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.35 : 0.7), [colors.primary, theme]);
const glassDisabledBackground = useMemo(() => withAlpha(colors.border, theme === 'dark' ? 0.35 : 0.5), [colors.border, theme]);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const [selectedForm, setSelectedForm] = useState<MedicationForm>('capsule');
const [dosageValue, setDosageValue] = useState('1');
const [dosageUnit, setDosageUnit] = useState<string>(DOSAGE_UNITS[0]);
const [unitPickerVisible, setUnitPickerVisible] = useState(false);
const [unitPickerValue, setUnitPickerValue] = useState(DOSAGE_UNITS[0]);
const [timesPerDay, setTimesPerDay] = useState(1);
const [timesPickerVisible, setTimesPickerVisible] = useState(false);
const [timesPickerValue, setTimesPickerValue] = useState(1);
const [startDate, setStartDate] = useState<Date>(new Date());
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
const [timePickerVisible, setTimePickerVisible] = useState(false);
const [timePickerDate, setTimePickerDate] = useState<Date>(createDateFromTime(DEFAULT_TIME_PRESETS[0]));
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
const [note, setNote] = useState('');
const [dictationActive, setDictationActive] = useState(false);
const [dictationLoading, setDictationLoading] = useState(false);
const isDictationSupported = Platform.OS === 'ios';
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 appendDictationResult = useCallback(
(text: string) => {
const clean = text.trim();
if (!clean) return;
setNote((prev) => {
if (!prev) {
return clean;
}
return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`;
});
},
[setNote]
);
const stepTitle = STEP_TITLES[currentStep] ?? STEP_TITLES[0];
const stepDescription = STEP_DESCRIPTIONS[currentStep] ?? '';
const canProceed = useMemo(() => {
switch (currentStep) {
case 0:
return medicationName.trim().length > 0;
case 1:
return Number(dosageValue) > 0 && !!dosageUnit && !!selectedForm;
case 2:
return timesPerDay > 0;
case 3:
return medicationTimes.length > 0;
default:
return true;
}
}, [currentStep, dosageUnit, dosageValue, medicationName, medicationTimes.length, selectedForm, timesPerDay]);
useEffect(() => {
if (!isDictationSupported) {
return;
}
Voice.onSpeechStart = () => {
setDictationActive(true);
setDictationLoading(false);
};
Voice.onSpeechEnd = () => {
setDictationActive(false);
setDictationLoading(false);
};
Voice.onSpeechResults = (event: any) => {
const recognized = event?.value?.[0];
if (recognized) {
appendDictationResult(recognized);
}
};
Voice.onSpeechError = (error: any) => {
console.log('[MEDICATION] voice error', error);
setDictationActive(false);
setDictationLoading(false);
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
};
return () => {
Voice.destroy()
.then(() => {
Voice.removeAllListeners();
})
.catch(() => {});
};
}, [appendDictationResult, isDictationSupported]);
const handleNext = useCallback(async () => {
if (!canProceed) return;
// 如果不是最后一步,继续下一步
if (currentStep < totalSteps - 1) {
setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1));
return;
}
// 最后一步:提交药物数据
setIsSubmitting(true);
try {
// 先检查用户是否已登录,如果未登录则跳转到登录页面
const isLoggedIn = await ensureLoggedIn({
shouldBack: true
});
if (!isLoggedIn) {
// 未登录ensureLoggedIn 已处理跳转,直接返回
setIsSubmitting(false);
return;
}
// 构建药物数据,符合 CreateMedicationDto 接口
const medicationData = {
name: medicationName.trim(),
photoUrl: photoUrl || undefined,
form: selectedForm,
dosageValue: Number(dosageValue),
dosageUnit: dosageUnit,
timesPerDay: timesPerDay,
medicationTimes: medicationTimes,
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
repeatPattern: 'daily' as RepeatPattern,
note: note.trim() || undefined,
};
// 调用 Redux action 创建药物
const result = await dispatch(createMedicationAction(medicationData)).unwrap();
// 刷新药物列表和记录数据,确保返回主界面能看到新数据
await dispatch(fetchMedications({ isActive: true }));
// 获取今天的记录,确保新添加的药物记录能显示
const today = dayjs().format('YYYY-MM-DD');
await dispatch(fetchMedicationRecords({ date: today }));
// 重新安排药品通知
try {
// 获取最新的药品列表
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
// 不影响添加药品的成功流程,只记录错误
}
// 成功提示
Alert.alert(
'添加成功',
`已成功添加药物"${medicationName}"`,
[
{
text: '确定',
onPress: () => router.back(),
},
]
);
} catch (error) {
console.error('[MEDICATION] 创建药物失败', error);
Alert.alert(
'添加失败',
error instanceof Error ? error.message : '创建药物时发生错误,请稍后重试',
[{ text: '确定' }]
);
} finally {
setIsSubmitting(false);
}
}, [
canProceed,
currentStep,
totalSteps,
medicationName,
photoUrl,
selectedForm,
dosageValue,
dosageUnit,
medicationTimes,
startDate,
note,
dispatch,
ensureLoggedIn,
]);
const handlePrev = useCallback(() => {
if (currentStep === 0) return;
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, [currentStep]);
const handleDictationPress = useCallback(async () => {
if (!isDictationSupported || dictationLoading) {
return;
}
try {
if (dictationActive) {
setDictationLoading(true);
await Voice.stop();
setDictationLoading(false);
return;
}
setDictationLoading(true);
try {
await Voice.stop();
} catch {
// no-op: safe to ignore if not already recording
}
await Voice.start('zh-CN');
} catch (error) {
console.log('[MEDICATION] unable to start dictation', error);
setDictationLoading(false);
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
}
}, [dictationActive, dictationLoading, isDictationSupported]);
// 处理图片选择(拍照或相册)
const handleSelectPhoto = useCallback(() => {
Alert.alert(
'选择图片',
'请选择图片来源',
[
{
text: '拍照',
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.9,
aspect: [9,16]
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
setPhotoUrl(null);
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
{ prefix: 'images/medications' }
);
setPhotoUrl(url);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请稍后重试');
}
} catch (error) {
console.error('[MEDICATION] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
}
},
},
{
text: '从相册选择',
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择药品照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9,
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
setPhotoUrl(null);
try {
const { url } = await upload(
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
{ prefix: 'images/medications' }
);
setPhotoUrl(url);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请稍后重试');
}
} catch (error) {
console.error('[MEDICATION] 从相册选择失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试');
}
},
},
{
text: '取消',
style: 'cancel',
},
],
{ cancelable: true }
);
}, [upload]);
const handleRemovePhoto = useCallback(() => {
setPhotoPreview(null);
setPhotoUrl(null);
}, []);
const openDatePicker = useCallback(() => {
setDatePickerValue(startDate);
setDatePickerVisible(true);
}, [startDate]);
const confirmStartDate = useCallback((date: Date) => {
setStartDate(date);
setDatePickerVisible(false);
}, []);
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);
});
}, []);
useEffect(() => {
setUnitPickerVisible(false);
setTimesPickerVisible(false);
}, [currentStep]);
const openUnitPicker = useCallback(() => {
setUnitPickerValue(dosageUnit);
setUnitPickerVisible(true);
}, [dosageUnit]);
const closeUnitPicker = useCallback(() => {
setUnitPickerVisible(false);
}, []);
const confirmUnitPicker = useCallback(() => {
setDosageUnit(unitPickerValue);
setUnitPickerVisible(false);
}, [unitPickerValue]);
const openTimesPicker = useCallback(() => {
setTimesPickerValue(timesPerDay);
setTimesPickerVisible(true);
}, [timesPerDay]);
const closeTimesPicker = useCallback(() => {
setTimesPickerVisible(false);
}, []);
const confirmTimesPicker = useCallback(() => {
setTimesPerDay(timesPickerValue);
setTimesPickerVisible(false);
}, [timesPickerValue]);
const renderStepContent = () => {
switch (currentStep) {
case 0:
return (
<View style={styles.stepSection}>
<View
style={[
styles.searchField,
{
backgroundColor: colors.surface,
borderColor: softBorderColor,
},
]}
>
<IconSymbol name="magnifyingglass" size={20} color={colors.textSecondary} />
<TextInput
value={medicationName}
onChangeText={setMedicationName}
placeholder="输入或搜索药品名称"
placeholderTextColor={colors.textMuted}
style={[styles.searchInput, { color: colors.text }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
</View>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.photoCard,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={handleSelectPhoto}
disabled={uploading}
>
{photoPreview ? (
<>
<Image source={{ uri: photoPreview }} style={styles.photoPreview} contentFit="cover" />
<View style={styles.photoOverlay}>
<Ionicons name="camera" size={18} color="#fff" />
<ThemedText style={styles.photoOverlayText}>
{uploading ? '上传中…' : '重新选择'}
</ThemedText>
</View>
<Pressable style={styles.photoRemoveBtn} onPress={handleRemovePhoto} hitSlop={12}>
<Ionicons name="close" size={16} color={colors.text} />
</Pressable>
</>
) : (
<View style={styles.photoPlaceholder}>
<View style={[styles.photoIconBadge, { backgroundColor: `${colors.primary}12` }]}>
<Ionicons name="camera" size={22} color={colors.primary} />
</View>
<ThemedText style={[styles.photoTitle, { color: colors.text }]}></ThemedText>
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}></ThemedText>
</View>
)}
{uploading && (
<View style={styles.photoUploadingIndicator}>
<ActivityIndicator color={colors.primary} size="small" />
<ThemedText style={[styles.uploadingText, { color: colors.textSecondary }]}></ThemedText>
</View>
)}
</TouchableOpacity>
</View>
);
case 1:
return (
<View style={styles.stepSection}>
<View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<View
style={[
styles.dosageField,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
>
<View style={styles.dosageRow}>
<TextInput
value={dosageValue}
onChangeText={setDosageValue}
keyboardType="decimal-pad"
placeholder="0.5"
placeholderTextColor={colors.textMuted}
style={[styles.dosageInput, { color: colors.text }]}
/>
<TouchableOpacity
activeOpacity={0.8}
onPress={openUnitPicker}
style={[
styles.unitSelector,
{
backgroundColor: fadedBorderFill,
},
]}
>
<ThemedText style={[styles.unitSelectorText, { color: colors.text }]}>{dosageUnit}</ThemedText>
<Ionicons name="chevron-down" size={16} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
</View>
<View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<View style={styles.formGrid}>
{FORM_OPTIONS.map((option) => {
const active = selectedForm === option.id;
return (
<TouchableOpacity
key={option.id}
style={[
styles.formOption,
{
borderColor: active ? colors.primary : softBorderColor,
backgroundColor: active ? `${colors.primary}10` : colors.surface,
},
]}
onPress={() => setSelectedForm(option.id)}
activeOpacity={0.9}
>
<View style={[styles.formIconBadge, { backgroundColor: active ? colors.primary : fadedBorderFill }]}>
<MaterialCommunityIcons
name={option.icon}
size={18}
color={active ? colors.onPrimary : colors.textSecondary}
/>
</View>
<ThemedText style={[styles.formLabel, { color: colors.text }]}>{option.label}</ThemedText>
</TouchableOpacity>
);
})}
</View>
</View>
</View>
);
case 2:
return (
<View style={styles.stepSection}>
<View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<TouchableOpacity
activeOpacity={0.85}
onPress={openTimesPicker}
style={[
styles.frequencyRow,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
>
<ThemedText style={[styles.frequencyValue, { color: colors.text }]}>{`${timesPerDay} 次/日`}</ThemedText>
<Ionicons name="chevron-down" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
);
case 3:
return (
<View style={styles.stepSection}>
<View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<View style={styles.timeList}>
{medicationTimes.map((time, index) => (
<View
key={`${time}-${index}`}
style={[
styles.timeItem,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
>
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
<Ionicons name="time" size={18} 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={18}
color={medicationTimes.length === 1 ? softBorderColor : colors.textSecondary}
/>
</Pressable>
</View>
))}
<TouchableOpacity style={[styles.addTimeButton, { borderColor: colors.primary }]} onPress={() => openTimePicker()}>
<Ionicons name="add" size={16} color={colors.primary} />
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}></ThemedText>
</TouchableOpacity>
</View>
</View>
</View>
);
case 4:
return (
<View style={styles.stepSection}>
<View style={styles.inputGroup}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
<View style={styles.noteInputWrapper}>
<TextInput
multiline
numberOfLines={4}
value={note}
onChangeText={setNote}
placeholder="记录注意事项、医生叮嘱或自定义提醒"
placeholderTextColor={colors.textMuted}
style={[
styles.noteInput,
{
borderColor: dictationActive ? colors.primary : softBorderColor,
backgroundColor: colors.surface,
color: colors.text,
},
]}
/>
{isDictationSupported && (
<TouchableOpacity
style={[
styles.noteVoiceButton,
{
borderColor: dictationActive ? colors.primary : softBorderColor,
backgroundColor: dictationActive ? colors.primary : colors.surface,
},
]}
activeOpacity={0.85}
onPress={handleDictationPress}
disabled={dictationLoading}
>
{dictationLoading ? (
<ActivityIndicator size="small" color={dictationActive ? colors.background : colors.primary} />
) : (
<Ionicons
name={dictationActive ? 'mic' : 'mic-outline'}
size={18}
color={dictationActive ? colors.background : colors.textSecondary}
/>
)}
</TouchableOpacity>
)}
</View>
</View>
</View>
);
default:
return null;
}
};
const showDateField = currentStep === 2;
return (
<View style={styles.screen}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff','#ffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar
title="添加药物"
onBack={() => router.back()}
withSafeTop={false}
transparent
variant="elevated"
/>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<ScrollView
style={styles.flex}
contentContainerStyle={[
{
paddingTop: insets.top + 72,
paddingBottom: Math.max(insets.bottom, 16) + 32,
},
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View
style={[
styles.formSurface,
]}
>
<View style={styles.stepIndicator}>
{Array.from({ length: totalSteps }).map((_, index) => {
const isActive = index <= currentStep;
return (
<View
key={index}
style={[
styles.stepSegment,
{ backgroundColor: isActive ? colors.primary : fadedBorderFill },
]}
/>
);
})}
</View>
<View style={styles.titleBlock}>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}>{stepTitle}</ThemedText>
<ThemedText style={[styles.modalSubtitle, { color: colors.textMuted }]}>{stepDescription}</ThemedText>
</View>
<View style={styles.contentContainer}>{renderStepContent()}</View>
<View style={styles.footer}>
{showDateField && (
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.startDateRow,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openDatePicker}
>
<View style={styles.startDateLeft}>
<Ionicons name="calendar" size={18} color={colors.textSecondary} />
<View>
<ThemedText style={[styles.startDateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.startDateValue, { color: colors.text }]}>
{dayjs(startDate).format('YYYY 年 MM 月 DD 日')}
</ThemedText>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
)}
<View style={styles.footerButtons}>
{currentStep > 0 && (
<TouchableOpacity
style={[styles.secondaryBtn, { borderColor: softBorderColor }]}
onPress={handlePrev}
>
<ThemedText style={[styles.secondaryBtnText, { color: colors.text }]}></ThemedText>
</TouchableOpacity>
)}
{glassAvailable ? (
<Pressable
style={[
styles.primaryBtnWrapper,
(!canProceed || uploading || isSubmitting) && styles.primaryBtnWrapperDisabled,
]}
disabled={!canProceed || uploading || isSubmitting}
onPress={handleNext}
>
<GlassView
style={[
styles.primaryBtn,
styles.glassPrimaryBtn,
{
backgroundColor: (!canProceed || uploading || isSubmitting)
? glassDisabledBackground
: glassPrimaryBackground,
},
]}
glassEffectStyle="clear"
tintColor={!canProceed || uploading || isSubmitting ? glassDisabledTint : glassPrimaryTint}
isInteractive={!(!canProceed || uploading || isSubmitting)}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.onPrimary} />
) : (
<ThemedText
style={[
styles.primaryBtnText,
{
color: !canProceed || uploading || isSubmitting ? colors.textSecondary : colors.onPrimary,
},
]}
>
{currentStep === totalSteps - 1 ? '完成' : '下一步'}
</ThemedText>
)}
</GlassView>
</Pressable>
) : (
<TouchableOpacity
activeOpacity={0.9}
style={[
styles.primaryBtn,
styles.primaryBtnFallback,
{
backgroundColor: canProceed && !uploading && !isSubmitting ? colors.primary : softBorderColor,
},
]}
disabled={!canProceed || uploading || isSubmitting}
onPress={handleNext}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.onPrimary} />
) : (
<ThemedText
style={[
styles.primaryBtnText,
{ color: canProceed && !uploading && !isSubmitting ? colors.onPrimary : colors.textSecondary },
]}
>
{currentStep === totalSteps - 1 ? '完成' : '下一步'}
</ThemedText>
)}
</TouchableOpacity>
)}
</View>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<Modal
visible={datePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setDatePickerVisible(false)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setDatePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<DateTimePicker
value={datePickerValue}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setDatePickerValue(date);
} else {
if (event.type === 'set' && date) {
confirmStartDate(date);
} else {
setDatePickerVisible(false);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable
onPress={() => setDatePickerVisible(false)}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmStartDate(datePickerValue)}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
)}
</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.modalTitle, { 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.modalActions}>
<Pressable
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmTime(timePickerDate)}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
<Modal
visible={timesPickerVisible}
transparent
animationType="fade"
onRequestClose={closeTimesPicker}
>
<Pressable style={styles.pickerBackdrop} onPress={closeTimesPicker} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<Picker
selectedValue={timesPickerValue}
onValueChange={(value) => setTimesPickerValue(Number(value))}
itemStyle={styles.unitPickerItem}
style={styles.unitPicker}
>
{TIMES_PER_DAY_OPTIONS.map((count) => (
<Picker.Item key={count} label={`${count} 次/日`} value={count} />
))}
</Picker>
<View style={styles.modalActions}>
<Pressable onPress={closeTimesPicker} style={[styles.modalBtn, { borderColor: softBorderColor }]}>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={confirmTimesPicker}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
</View>
</Modal>
<Modal
visible={unitPickerVisible}
transparent
animationType="fade"
onRequestClose={closeUnitPicker}
>
<Pressable style={styles.pickerBackdrop} onPress={closeUnitPicker} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<Picker
selectedValue={unitPickerValue}
onValueChange={(value) => setUnitPickerValue(String(value))}
itemStyle={styles.unitPickerItem}
style={styles.unitPicker}
>
{DOSAGE_UNITS.map((unit) => (
<Picker.Item key={unit} label={unit} value={unit} />
))}
</Picker>
<View style={styles.modalActions}>
<Pressable onPress={closeUnitPicker} style={[styles.modalBtn, { borderColor: softBorderColor }]}>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={confirmUnitPicker}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
flex: {
flex: 1,
},
pageContent: {
gap: 24,
},
formSurface: {
paddingHorizontal: 24,
gap: 20,
width: '100%',
},
stepIndicator: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
stepSegment: {
flex: 1,
height: 4,
borderRadius: 2,
},
titleBlock: {
gap: 6,
},
modalTitle: {
fontSize: 22,
fontWeight: '600',
},
modalSubtitle: {
fontSize: 14,
lineHeight: 20,
},
contentContainer: {
paddingBottom: 16,
gap: 20,
},
stepSection: {
gap: 20,
},
searchField: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
borderRadius: 16,
borderWidth: 1,
height: 56,
gap: 12,
},
searchInput: {
flex: 1,
fontSize: 16,
paddingVertical: 0,
},
photoCard: {
borderWidth: 1,
borderRadius: 20,
height: 180,
overflow: 'hidden',
justifyContent: 'center',
},
photoPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingHorizontal: 20,
},
photoIconBadge: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
},
photoTitle: {
fontSize: 16,
fontWeight: '600',
},
photoSubtitle: {
fontSize: 13,
textAlign: 'center',
},
photoPreview: {
width: '100%',
height: '100%',
},
photoOverlay: {
position: 'absolute',
right: 16,
top: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: 'rgba(15, 23, 42, 0.55)',
borderRadius: 999,
},
photoOverlayText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
photoUploadingIndicator: {
position: 'absolute',
bottom: 16,
left: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
borderRadius: 999,
},
uploadingText: {
fontSize: 12,
fontWeight: '600',
},
photoRemoveBtn: {
position: 'absolute',
right: 12,
bottom: 12,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.85)',
alignItems: 'center',
justifyContent: 'center',
},
inputGroup: {
gap: 12,
},
groupLabel: {
fontSize: 14,
fontWeight: '600',
letterSpacing: 0.2,
},
dosageField: {
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 12,
position: 'relative',
overflow: 'visible',
},
dosageInput: {
fontSize: 28,
fontWeight: '600',
flex: 1,
paddingVertical: 0,
},
dosageRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
unitSelector: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
unitSelectorText: {
fontSize: 14,
fontWeight: '600',
},
unitPicker: {
width: '100%',
},
unitPickerItem: {
fontSize: 16,
},
formGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
formOption: {
flexBasis: '30%',
borderWidth: 1,
borderRadius: 16,
paddingVertical: 6,
alignItems: 'center',
justifyContent: 'center',
},
formIconBadge: {
width: 28,
height: 28,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
formLabel: {
fontSize: 11,
fontWeight: '600',
},
frequencyRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
frequencyValue: {
fontSize: 18,
fontWeight: '600',
},
stepperBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
frequencyLabel: {
fontSize: 16,
fontWeight: '600',
},
unitSwitch: {
flexDirection: 'row',
gap: 12,
marginTop: 10,
},
unitOption: {
flex: 1,
paddingVertical: 12,
borderWidth: 1,
borderRadius: 14,
alignItems: 'center',
},
timeList: {
gap: 12,
},
timeItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 12,
},
timeValue: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
timeText: {
fontSize: 18,
fontWeight: '600',
},
addTimeButton: {
borderWidth: 1,
borderRadius: 16,
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
addTimeLabel: {
fontSize: 14,
fontWeight: '600',
},
noteInput: {
borderWidth: 1,
borderRadius: 20,
padding: 16,
paddingRight: 72,
paddingBottom: 56,
minHeight: 140,
textAlignVertical: 'top',
fontSize: 15,
lineHeight: 22,
},
noteInputWrapper: {
position: 'relative',
},
noteVoiceButton: {
position: 'absolute',
right: 16,
bottom: 16,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
footer: {
gap: 12,
},
startDateRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 12,
},
startDateLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
startDateLabel: {
fontSize: 12,
},
startDateValue: {
fontSize: 16,
fontWeight: '600',
},
footerButtons: {
flexDirection: 'row',
gap: 48,
alignItems: 'center',
},
secondaryBtn: {
paddingVertical: 14,
paddingHorizontal: 20,
backgroundColor: '#F2F2F2'
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
color: '#475569',
},
primaryBtn: {
flex: 1,
paddingVertical: 16,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
primaryBtnWrapper: {
flex: 1,
},
primaryBtnWrapperDisabled: {
opacity: 0.6,
},
glassPrimaryBtn: {
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.2)',
},
primaryBtnFallback: {
justifyContent: 'center',
},
primaryBtnText: {
fontSize: 16,
fontWeight: '700',
},
pickerBackdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
pickerSheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
marginTop: 16,
},
modalBtn: {
flex: 1,
borderRadius: 16,
paddingVertical: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
},
modalBtnPrimary: {
backgroundColor: '#4F46E5',
borderColor: 'transparent',
},
modalBtnText: {
fontSize: 15,
fontWeight: '600',
color: '#475569',
},
modalBtnTextPrimary: {
color: '#fff',
},
});