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

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

1898 lines
61 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 { 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.25), [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 cardShadowColor = useMemo(
() => (theme === 'dark' ? 'rgba(15, 23, 42, 0.45)' : 'rgba(15, 23, 42, 0.16)'),
[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 [endDate, setEndDate] = useState<Date | null>(null);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
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 [currentDictationText, setCurrentDictationText] = useState('');
// 记录语音识别开始前的文本,用于取消时恢复
const [noteBeforeDictation, setNoteBeforeDictation] = useState('');
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 updateDictationResult = useCallback(
(text: string) => {
const clean = text.trim();
if (!clean) return;
// 实时更新:用识别的新文本替换当前识别文本
setCurrentDictationText(clean);
// 同步更新到 note 中,以便用户能看到实时效果
setNote((prev) => {
// 移除之前的语音识别文本,添加新的识别文本
const baseText = noteBeforeDictation;
if (!baseText) {
return clean;
}
// 在原文本后追加,确保格式正确
return `${baseText}${baseText.endsWith('\n') ? '' : '\n'}${clean}`;
});
},
[noteBeforeDictation]
);
// 确认语音识别结果
const confirmDictationResult = useCallback(() => {
// 语音识别结束,确认当前文本
setCurrentDictationText('');
setNoteBeforeDictation('');
}, []);
// 取消语音识别
const cancelDictationResult = useCallback(() => {
// 恢复到语音识别前的文本
setNote(noteBeforeDictation);
setCurrentDictationText('');
setNoteBeforeDictation('');
}, [noteBeforeDictation]);
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 = () => {
// 语音识别结束,确认识别结果
confirmDictationResult();
setDictationActive(false);
setDictationLoading(false);
};
Voice.onSpeechResults = (event: any) => {
// 获取最新的识别结果(这是累积的结果,包含之前说过的内容)
const recognized = event?.value?.[0];
if (recognized) {
// 实时更新识别结果,替换式而非追加式
updateDictationResult(recognized);
}
};
Voice.onSpeechError = (error: any) => {
console.log('[MEDICATION] voice error', error);
// 发生错误时取消识别
cancelDictationResult();
setDictationActive(false);
setDictationLoading(false);
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
};
return () => {
Voice.destroy()
.then(() => {
Voice.removeAllListeners();
})
.catch(() => {});
};
}, [updateDictationResult, confirmDictationResult, cancelDictationResult, 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 格式
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
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,
endDate,
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();
// Voice.onSpeechEnd 会自动确认结果
setDictationLoading(false);
return;
}
// 开始录音前,保存当前的文本内容
setNoteBeforeDictation(note);
setCurrentDictationText('');
setDictationLoading(true);
try {
// 确保之前的录音已停止
await Voice.stop();
} catch {
// 忽略错误如果之前没有录音stop 会抛出异常
}
// 开始语音识别
await Voice.start('zh-CN');
} catch (error) {
console.log('[MEDICATION] unable to start dictation', error);
setDictationLoading(false);
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
}
}, [dictationActive, dictationLoading, isDictationSupported, note]);
// 处理图片选择(拍照或相册)
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.3,
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({
mediaTypes: ['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 openStartDatePicker = useCallback(() => {
setDatePickerValue(startDate);
setDatePickerVisible(true);
}, [startDate]);
const openEndDatePicker = useCallback(() => {
setEndDatePickerValue(endDate || new Date());
setEndDatePickerVisible(true);
}, [endDate]);
const openExpiryDatePicker = useCallback(() => {
setExpiryDatePickerValue(expiryDate || new Date());
setExpiryDatePickerVisible(true);
}, [expiryDate]);
const confirmStartDate = useCallback((date: Date) => {
// 验证开始日期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(date);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate < today) {
Alert.alert('日期无效', '开始日期不能早于今天');
return;
}
setStartDate(date);
setDatePickerVisible(false);
// 如果结束日期早于新的开始日期,清空结束日期
if (endDate && endDate < date) {
setEndDate(null);
}
}, [endDate]);
const confirmEndDate = useCallback((date: Date) => {
// 验证结束日期不能早于开始日期
if (date < startDate) {
Alert.alert('日期无效', '结束日期不能早于开始日期');
return;
}
setEndDate(date);
setEndDatePickerVisible(false);
}, [startDate]);
const confirmExpiryDate = useCallback((date: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(date);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
setExpiryDate(date);
setExpiryDatePickerVisible(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,
styles.inputShadow,
{
backgroundColor: colors.surface,
shadowColor: cardShadowColor,
},
]}
>
<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="contain" />
<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,
styles.inputShadow,
{
backgroundColor: colors.surface,
shadowColor: cardShadowColor,
},
]}
>
<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 style={styles.inputGroup}>
<View style={styles.periodHeader}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
</View>
<View style={styles.dateRowContainer}>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.dateRow,
styles.dateRowHalf,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openStartDatePicker}
>
<View style={styles.dateLeft}>
<Ionicons name="calendar" size={16} color={colors.textSecondary} />
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
{dayjs(startDate).format('MM/DD')}
</ThemedText>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.dateRow,
styles.dateRowHalf,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openEndDatePicker}
>
<View style={styles.dateLeft}>
<Ionicons name="calendar-outline" size={16} color={colors.textSecondary} />
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
{endDate ? dayjs(endDate).format('MM/DD') : '长期'}
</ThemedText>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.inputGroup}>
<View style={styles.periodHeader}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
</View>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.dateRow,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openExpiryDatePicker}
>
<View style={styles.dateLeft}>
<Ionicons name="time-outline" size={16} color={colors.textSecondary} />
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
{expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'}
</ThemedText>
</View>
<Ionicons name="chevron-forward" size={16} 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,
styles.inputShadow,
{
backgroundColor: colors.surface,
shadowColor: dictationActive ? colors.primary : cardShadowColor,
},
]}
>
<TextInput
multiline
numberOfLines={4}
value={note}
onChangeText={setNote}
placeholder="记录注意事项、医生叮嘱或自定义提醒"
placeholderTextColor={colors.textMuted}
style={[
styles.noteInput,
{
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}>
<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 }]}
>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<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={expiryDatePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setExpiryDatePickerVisible(false)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setExpiryDatePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<DateTimePicker
value={expiryDatePickerValue}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setExpiryDatePickerValue(date);
} else {
if (event.type === 'set' && date) {
confirmExpiryDate(date);
} else {
setExpiryDatePickerVisible(false);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable
onPress={() => setExpiryDatePickerVisible(false)}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmExpiryDate(expiryDatePickerValue)}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
<Modal
visible={endDatePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setEndDatePickerVisible(false)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setEndDatePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<DateTimePicker
value={endDatePickerValue}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setEndDatePickerValue(date);
} else {
if (event.type === 'set' && date) {
confirmEndDate(date);
} else {
setEndDatePickerVisible(false);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable
onPress={() => setEndDatePickerVisible(false)}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmEndDate(endDatePickerValue)}
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,
height: 56,
gap: 12,
},
inputShadow: {
borderWidth: 0,
shadowColor: 'rgba(15, 23, 42, 0.16)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 14,
elevation: 6,
},
searchInput: {
flex: 1,
fontSize: 16,
paddingVertical: 0,
},
photoCard: {
borderWidth: 1,
borderRadius: 20,
height: 240,
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: {
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: {
borderRadius: 20,
padding: 16,
paddingRight: 72,
paddingBottom: 56,
minHeight: 140,
textAlignVertical: 'top',
fontSize: 15,
lineHeight: 22,
},
noteInputWrapper: {
position: 'relative',
borderRadius: 24,
},
noteVoiceButton: {
position: 'absolute',
right: 16,
bottom: 16,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
footer: {
gap: 12,
},
periodHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dateRowContainer: {
flexDirection: 'row',
gap: 12,
},
dateRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 18,
paddingHorizontal: 12,
paddingVertical: 10,
},
dateRowHalf: {
flex: 1,
},
dateLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
dateLabel: {
fontSize: 11,
},
dateValue: {
fontSize: 14,
fontWeight: '600',
},
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',
},
});