Files
digital-pilates/app/medications/add-medication.tsx
richarjiang 25b8e45af8 feat(medications): 实现完整的用药管理功能
添加了药物管理的核心功能,包括:
- 药物列表展示和状态管理
- 添加新药物的完整流程
- 服药记录的创建和状态更新
- 药物管理界面,支持激活/停用操作
- Redux状态管理和API服务层
- 相关类型定义和辅助函数

主要文件:
- app/(tabs)/medications.tsx - 主界面,集成Redux数据
- app/medications/add-medication.tsx - 添加药物流程
- app/medications/manage-medications.tsx - 药物管理界面
- store/medicationsSlice.ts - Redux状态管理
- services/medications.ts - API服务层
- types/medication.ts - 类型定义
2025-11-10 10:02:53 +08:00

1438 lines
45 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 { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
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 { 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 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 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) => {
const [hour, minute] = time.split(':').map((val) => parseInt(val, 10));
const next = new Date();
next.setHours(hour || 0, minute || 0, 0, 0);
return next;
};
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 softBorderColor = useMemo(() => withAlpha(colors.border, 0.45), [colors.border]);
const fadedBorderFill = useMemo(() => withAlpha(colors.border, 0.2), [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 {
// 构建药物数据,符合 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 }));
// 成功提示
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,
]);
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 handleTakePhoto = useCallback(async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
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('拍照失败', '无法打开相机,请稍后再试');
}
}, [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) => {
if (typeof index === 'number') {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimes[index]));
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
}
setTimePickerVisible(true);
},
[medicationTimes]
);
const confirmTime = useCallback(
(date: Date) => {
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);
},
[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={handleTakePhoto}
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, { backgroundColor: colors.background }]}>
<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,
{
backgroundColor: colors.pageBackgroundEmphasis,
},
]}
>
<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)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setTimePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<DateTimePicker
value={timePickerDate}
mode="time"
display={Platform.OS === 'ios' ? 'spinner' : 'clock'}
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,
},
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: 12,
alignItems: 'center',
},
secondaryBtn: {
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 18,
borderWidth: 1,
borderColor: '#E2E8F0',
},
secondaryBtnText: {
fontSize: 15,
fontWeight: '600',
color: '#475569',
},
primaryBtn: {
flex: 1,
paddingVertical: 16,
borderRadius: 18,
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',
},
});