Files
digital-pilates/app/medications/add-medication.tsx
richarjiang 7bd0b5fc52 # 方案总结
基于提供的 Git diff,我将生成以下 conventional commit message:

## 变更分析:

1. **核心功能**:
   - 新增睡眠监控服务(`services/sleepMonitor.ts`)
   - 新增睡眠通知服务(`services/sleepNotificationService.ts`)
   - iOS 原生端增加睡眠观察者方法

2. **应用启动优化**:
   - 重构 `app/_layout.tsx` 中的初始化流程,按优先级分阶段加载服务

3. **药品功能改进**:
   - 优化语音识别交互(实时预览、可取消)
   - Widget 增加 URL scheme 支持

4. **路由配置**:
   - 新增药品管理路由常量

## 提交信息类型:
- **主类型**:`feat` (新增睡眠监控功能)
- **作用域**:`health` (健康相关功能)

---

请确认方案后,我将生成最终的 commit message。

---

**最终 Commit Message:**

feat(health): 添加睡眠监控和通知服务,优化应用启动流程

- 新增睡眠监控服务,支持实时监听 HealthKit 睡眠数据更新
- 实现睡眠质量分析算法,计算睡眠评分和各阶段占比
- 新增睡眠通知服务,分析完成后自动推送质量评估和建议
- iOS 原生端实现睡眠数据观察者,支持后台数据传递
- 重构应用启动初始化流程,按优先级分阶段加载服务(关键/次要/后台/空闲)
- 优化药品录入页面语音识别交互,支持实时预览和取消操作
- 药品 Widget 增加 deeplink 支持,点击跳转到应用
- 新增药品管理路由常量配置
2025-11-14 10:52:26 +08:00

1813 lines
58 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.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 [datePickerVisible, setDatePickerVisible] = useState(false);
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
const [endDatePickerValue, setEndDatePickerValue] = 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, // 如果有结束日期,设置为当天结束时间
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,
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.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({
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 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 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>
);
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={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',
},
});