- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消 - 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制 - 重构药品详情页面,移除频率编辑功能到独立页面 - 优化药品添加流程,支持拍照和相册选择图片 - 改进通知权限检查和错误处理机制 - 更新用户偏好设置,添加药品提醒开关配置
1573 lines
50 KiB
TypeScript
1573 lines
50 KiB
TypeScript
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 { router } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
KeyboardAvoidingView,
|
||
Modal,
|
||
Platform,
|
||
Pressable,
|
||
ScrollView,
|
||
StyleSheet,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
type ThemeColors = typeof Colors.light | typeof Colors.dark;
|
||
|
||
const HEX_COLOR_REGEX = /^#([0-9a-f]{6})$/i;
|
||
|
||
const withAlpha = (hex: string, alpha: number) => {
|
||
if (!HEX_COLOR_REGEX.test(hex)) {
|
||
return hex;
|
||
}
|
||
const normalized = hex.replace('#', '');
|
||
const r = parseInt(normalized.slice(0, 2), 16);
|
||
const g = parseInt(normalized.slice(2, 4), 16);
|
||
const b = parseInt(normalized.slice(4, 6), 16);
|
||
const safeAlpha = Math.min(Math.max(alpha, 0), 1);
|
||
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
|
||
};
|
||
|
||
// 表单数据接口(用于内部状态管理)
|
||
interface AddMedicationFormData {
|
||
name: string;
|
||
photoUrl?: string | null;
|
||
form: MedicationForm;
|
||
dosageValue: string;
|
||
dosageUnit: string;
|
||
timesPerDay: number;
|
||
medicationTimes: string[];
|
||
startDate: string;
|
||
note: string;
|
||
}
|
||
|
||
const TIMES_PER_DAY_OPTIONS = Array.from({ length: 10 }, (_, index) => index + 1);
|
||
const STEP_TITLES = ['药品名称', '剂型与剂量', '服药频率', '服药时间', '备注'];
|
||
const STEP_DESCRIPTIONS = [
|
||
'为药物命名并上传包装照片,方便识别',
|
||
'选择药片类型并填写每次的用药剂量',
|
||
'设置用药频率以及每日次数',
|
||
'添加并管理每天的提醒时间',
|
||
'填写备注或医生叮嘱(可选)',
|
||
];
|
||
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
|
||
|
||
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
|
||
const getDefaultTimeByIndex = (index: number) => DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
|
||
const createDateFromTime = (time: string) => {
|
||
try {
|
||
if (!time || typeof time !== 'string') {
|
||
console.warn('[MEDICATION] Invalid time string provided:', time);
|
||
return new Date();
|
||
}
|
||
|
||
const parts = time.split(':');
|
||
if (parts.length !== 2) {
|
||
console.warn('[MEDICATION] Invalid time format:', time);
|
||
return new Date();
|
||
}
|
||
|
||
const hour = parseInt(parts[0], 10);
|
||
const minute = parseInt(parts[1], 10);
|
||
|
||
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||
console.warn('[MEDICATION] Invalid time values:', { hour, minute });
|
||
return new Date();
|
||
}
|
||
|
||
const next = new Date();
|
||
next.setHours(hour, minute, 0, 0);
|
||
|
||
// 验证日期是否有效
|
||
if (isNaN(next.getTime())) {
|
||
console.error('[MEDICATION] Failed to create valid date');
|
||
return new Date();
|
||
}
|
||
|
||
return next;
|
||
} catch (error) {
|
||
console.error('[MEDICATION] Error in createDateFromTime:', error);
|
||
return new Date();
|
||
}
|
||
};
|
||
|
||
export default function AddMedicationScreen() {
|
||
const dispatch = useAppDispatch();
|
||
const insets = useSafeAreaInsets();
|
||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||
const colors: ThemeColors = Colors[theme];
|
||
const glassAvailable = isLiquidGlassAvailable();
|
||
const totalSteps = STEP_TITLES.length;
|
||
const [currentStep, setCurrentStep] = useState(0);
|
||
const [medicationName, setMedicationName] = useState('');
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const { upload, uploading } = useCosUpload({ prefix: 'images/medications' });
|
||
// 获取登录验证相关的功能
|
||
const { ensureLoggedIn } = useAuthGuard();
|
||
const softBorderColor = useMemo(() => withAlpha(colors.border, 0.45), [colors.border]);
|
||
const fadedBorderFill = useMemo(() => withAlpha(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 {
|
||
// 先检查用户是否已登录,如果未登录则跳转到登录页面
|
||
const isLoggedIn = await ensureLoggedIn({
|
||
shouldBack: true
|
||
});
|
||
if (!isLoggedIn) {
|
||
// 未登录,ensureLoggedIn 已处理跳转,直接返回
|
||
setIsSubmitting(false);
|
||
return;
|
||
}
|
||
|
||
// 构建药物数据,符合 CreateMedicationDto 接口
|
||
const medicationData = {
|
||
name: medicationName.trim(),
|
||
photoUrl: photoUrl || undefined,
|
||
form: selectedForm,
|
||
dosageValue: Number(dosageValue),
|
||
dosageUnit: dosageUnit,
|
||
timesPerDay: timesPerDay,
|
||
medicationTimes: medicationTimes,
|
||
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
|
||
repeatPattern: 'daily' as RepeatPattern,
|
||
note: note.trim() || undefined,
|
||
};
|
||
|
||
// 调用 Redux action 创建药物
|
||
const result = await dispatch(createMedicationAction(medicationData)).unwrap();
|
||
|
||
// 刷新药物列表和记录数据,确保返回主界面能看到新数据
|
||
await dispatch(fetchMedications({ isActive: true }));
|
||
|
||
// 获取今天的记录,确保新添加的药物记录能显示
|
||
const today = dayjs().format('YYYY-MM-DD');
|
||
await dispatch(fetchMedicationRecords({ date: today }));
|
||
|
||
// 重新安排药品通知
|
||
try {
|
||
// 获取最新的药品列表
|
||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
|
||
} catch (error) {
|
||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||
// 不影响添加药品的成功流程,只记录错误
|
||
}
|
||
|
||
// 成功提示
|
||
Alert.alert(
|
||
'添加成功',
|
||
`已成功添加药物"${medicationName}"`,
|
||
[
|
||
{
|
||
text: '确定',
|
||
onPress: () => router.back(),
|
||
},
|
||
]
|
||
);
|
||
} catch (error) {
|
||
console.error('[MEDICATION] 创建药物失败', error);
|
||
Alert.alert(
|
||
'添加失败',
|
||
error instanceof Error ? error.message : '创建药物时发生错误,请稍后重试',
|
||
[{ text: '确定' }]
|
||
);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
}, [
|
||
canProceed,
|
||
currentStep,
|
||
totalSteps,
|
||
medicationName,
|
||
photoUrl,
|
||
selectedForm,
|
||
dosageValue,
|
||
dosageUnit,
|
||
medicationTimes,
|
||
startDate,
|
||
note,
|
||
dispatch,
|
||
ensureLoggedIn,
|
||
]);
|
||
|
||
const handlePrev = useCallback(() => {
|
||
if (currentStep === 0) return;
|
||
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||
}, [currentStep]);
|
||
|
||
const handleDictationPress = useCallback(async () => {
|
||
if (!isDictationSupported || dictationLoading) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (dictationActive) {
|
||
setDictationLoading(true);
|
||
await Voice.stop();
|
||
setDictationLoading(false);
|
||
return;
|
||
}
|
||
|
||
setDictationLoading(true);
|
||
try {
|
||
await Voice.stop();
|
||
} catch {
|
||
// no-op: safe to ignore if not already recording
|
||
}
|
||
await Voice.start('zh-CN');
|
||
} catch (error) {
|
||
console.log('[MEDICATION] unable to start dictation', error);
|
||
setDictationLoading(false);
|
||
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
|
||
}
|
||
}, [dictationActive, dictationLoading, isDictationSupported]);
|
||
|
||
// 处理图片选择(拍照或相册)
|
||
const handleSelectPhoto = useCallback(() => {
|
||
Alert.alert(
|
||
'选择图片',
|
||
'请选择图片来源',
|
||
[
|
||
{
|
||
text: '拍照',
|
||
onPress: async () => {
|
||
try {
|
||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||
if (permission.status !== 'granted') {
|
||
Alert.alert('权限不足', '需要相机权限以拍摄药品照片');
|
||
return;
|
||
}
|
||
|
||
const result = await ImagePicker.launchCameraAsync({
|
||
allowsEditing: true,
|
||
quality: 0.9,
|
||
aspect: [9,16]
|
||
});
|
||
|
||
if (result.canceled || !result.assets?.length) {
|
||
return;
|
||
}
|
||
|
||
const asset = result.assets[0];
|
||
setPhotoPreview(asset.uri);
|
||
setPhotoUrl(null);
|
||
|
||
try {
|
||
const { url } = await upload(
|
||
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
|
||
{ prefix: 'images/medications' }
|
||
);
|
||
setPhotoUrl(url);
|
||
} catch (uploadError) {
|
||
console.error('[MEDICATION] 图片上传失败', uploadError);
|
||
Alert.alert('上传失败', '图片上传失败,请稍后重试');
|
||
}
|
||
} catch (error) {
|
||
console.error('[MEDICATION] 拍照失败', error);
|
||
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
|
||
}
|
||
},
|
||
},
|
||
{
|
||
text: '从相册选择',
|
||
onPress: async () => {
|
||
try {
|
||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||
if (permission.status !== 'granted') {
|
||
Alert.alert('权限不足', '需要相册权限以选择药品照片');
|
||
return;
|
||
}
|
||
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
allowsEditing: true,
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
quality: 0.9,
|
||
});
|
||
|
||
if (result.canceled || !result.assets?.length) {
|
||
return;
|
||
}
|
||
|
||
const asset = result.assets[0];
|
||
setPhotoPreview(asset.uri);
|
||
setPhotoUrl(null);
|
||
|
||
try {
|
||
const { url } = await upload(
|
||
{ uri: asset.uri, name: asset.fileName ?? `medication-${Date.now()}.jpg`, type: asset.mimeType ?? 'image/jpeg' },
|
||
{ prefix: 'images/medications' }
|
||
);
|
||
setPhotoUrl(url);
|
||
} catch (uploadError) {
|
||
console.error('[MEDICATION] 图片上传失败', uploadError);
|
||
Alert.alert('上传失败', '图片上传失败,请稍后重试');
|
||
}
|
||
} catch (error) {
|
||
console.error('[MEDICATION] 从相册选择失败', error);
|
||
Alert.alert('选择失败', '无法打开相册,请稍后再试');
|
||
}
|
||
},
|
||
},
|
||
{
|
||
text: '取消',
|
||
style: 'cancel',
|
||
},
|
||
],
|
||
{ cancelable: true }
|
||
);
|
||
}, [upload]);
|
||
|
||
const handleRemovePhoto = useCallback(() => {
|
||
setPhotoPreview(null);
|
||
setPhotoUrl(null);
|
||
}, []);
|
||
|
||
const openDatePicker = useCallback(() => {
|
||
setDatePickerValue(startDate);
|
||
setDatePickerVisible(true);
|
||
}, [startDate]);
|
||
|
||
const confirmStartDate = useCallback((date: Date) => {
|
||
setStartDate(date);
|
||
setDatePickerVisible(false);
|
||
}, []);
|
||
|
||
const openTimePicker = useCallback(
|
||
(index?: number) => {
|
||
try {
|
||
if (typeof index === 'number') {
|
||
if (index >= 0 && index < medicationTimes.length) {
|
||
setEditingTimeIndex(index);
|
||
setTimePickerDate(createDateFromTime(medicationTimes[index]));
|
||
} else {
|
||
console.error('[MEDICATION] Invalid time index:', index);
|
||
return;
|
||
}
|
||
} else {
|
||
setEditingTimeIndex(null);
|
||
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
|
||
}
|
||
setTimePickerVisible(true);
|
||
} catch (error) {
|
||
console.error('[MEDICATION] Error in openTimePicker:', error);
|
||
}
|
||
},
|
||
[medicationTimes]
|
||
);
|
||
|
||
const confirmTime = useCallback(
|
||
(date: Date) => {
|
||
try {
|
||
if (!date || isNaN(date.getTime())) {
|
||
console.error('[MEDICATION] Invalid date provided to confirmTime');
|
||
setTimePickerVisible(false);
|
||
setEditingTimeIndex(null);
|
||
return;
|
||
}
|
||
|
||
const nextValue = formatTime(date);
|
||
setMedicationTimes((prev) => {
|
||
if (editingTimeIndex == null) {
|
||
return [...prev, nextValue];
|
||
}
|
||
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
|
||
});
|
||
setTimePickerVisible(false);
|
||
setEditingTimeIndex(null);
|
||
} catch (error) {
|
||
console.error('[MEDICATION] Error in confirmTime:', error);
|
||
setTimePickerVisible(false);
|
||
setEditingTimeIndex(null);
|
||
}
|
||
},
|
||
[editingTimeIndex]
|
||
);
|
||
|
||
const removeTime = useCallback((index: number) => {
|
||
setMedicationTimes((prev) => {
|
||
if (prev.length === 1) {
|
||
return prev;
|
||
}
|
||
return prev.filter((_, idx) => idx !== index);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setUnitPickerVisible(false);
|
||
setTimesPickerVisible(false);
|
||
}, [currentStep]);
|
||
|
||
const openUnitPicker = useCallback(() => {
|
||
setUnitPickerValue(dosageUnit);
|
||
setUnitPickerVisible(true);
|
||
}, [dosageUnit]);
|
||
|
||
const closeUnitPicker = useCallback(() => {
|
||
setUnitPickerVisible(false);
|
||
}, []);
|
||
|
||
const confirmUnitPicker = useCallback(() => {
|
||
setDosageUnit(unitPickerValue);
|
||
setUnitPickerVisible(false);
|
||
}, [unitPickerValue]);
|
||
|
||
const openTimesPicker = useCallback(() => {
|
||
setTimesPickerValue(timesPerDay);
|
||
setTimesPickerVisible(true);
|
||
}, [timesPerDay]);
|
||
|
||
const closeTimesPicker = useCallback(() => {
|
||
setTimesPickerVisible(false);
|
||
}, []);
|
||
|
||
const confirmTimesPicker = useCallback(() => {
|
||
setTimesPerDay(timesPickerValue);
|
||
setTimesPickerVisible(false);
|
||
}, [timesPickerValue]);
|
||
|
||
const renderStepContent = () => {
|
||
switch (currentStep) {
|
||
case 0:
|
||
return (
|
||
<View style={styles.stepSection}>
|
||
<View
|
||
style={[
|
||
styles.searchField,
|
||
{
|
||
backgroundColor: colors.surface,
|
||
borderColor: softBorderColor,
|
||
},
|
||
]}
|
||
>
|
||
<IconSymbol name="magnifyingglass" size={20} color={colors.textSecondary} />
|
||
<TextInput
|
||
value={medicationName}
|
||
onChangeText={setMedicationName}
|
||
placeholder="输入或搜索药品名称"
|
||
placeholderTextColor={colors.textMuted}
|
||
style={[styles.searchInput, { color: colors.text }]}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
autoFocus
|
||
/>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
activeOpacity={0.85}
|
||
style={[
|
||
styles.photoCard,
|
||
{
|
||
borderColor: softBorderColor,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
onPress={handleSelectPhoto}
|
||
disabled={uploading}
|
||
>
|
||
{photoPreview ? (
|
||
<>
|
||
<Image source={{ uri: photoPreview }} style={styles.photoPreview} contentFit="cover" />
|
||
<View style={styles.photoOverlay}>
|
||
<Ionicons name="camera" size={18} color="#fff" />
|
||
<ThemedText style={styles.photoOverlayText}>
|
||
{uploading ? '上传中…' : '重新选择'}
|
||
</ThemedText>
|
||
</View>
|
||
<Pressable style={styles.photoRemoveBtn} onPress={handleRemovePhoto} hitSlop={12}>
|
||
<Ionicons name="close" size={16} color={colors.text} />
|
||
</Pressable>
|
||
</>
|
||
) : (
|
||
<View style={styles.photoPlaceholder}>
|
||
<View style={[styles.photoIconBadge, { backgroundColor: `${colors.primary}12` }]}>
|
||
<Ionicons name="camera" size={22} color={colors.primary} />
|
||
</View>
|
||
<ThemedText style={[styles.photoTitle, { color: colors.text }]}>上传药品图片</ThemedText>
|
||
<ThemedText style={[styles.photoSubtitle, { color: colors.textMuted }]}>拍照或从相册选择,辅助识别药品包装</ThemedText>
|
||
</View>
|
||
)}
|
||
{uploading && (
|
||
<View style={styles.photoUploadingIndicator}>
|
||
<ActivityIndicator color={colors.primary} size="small" />
|
||
<ThemedText style={[styles.uploadingText, { color: colors.textSecondary }]}>正在上传</ThemedText>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
case 1:
|
||
return (
|
||
<View style={styles.stepSection}>
|
||
<View style={styles.inputGroup}>
|
||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>每次剂量</ThemedText>
|
||
<View
|
||
style={[
|
||
styles.dosageField,
|
||
{
|
||
borderColor: softBorderColor,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
>
|
||
<View style={styles.dosageRow}>
|
||
<TextInput
|
||
value={dosageValue}
|
||
onChangeText={setDosageValue}
|
||
keyboardType="decimal-pad"
|
||
placeholder="0.5"
|
||
placeholderTextColor={colors.textMuted}
|
||
style={[styles.dosageInput, { color: colors.text }]}
|
||
/>
|
||
<TouchableOpacity
|
||
activeOpacity={0.8}
|
||
onPress={openUnitPicker}
|
||
style={[
|
||
styles.unitSelector,
|
||
{
|
||
backgroundColor: fadedBorderFill,
|
||
},
|
||
]}
|
||
>
|
||
<ThemedText style={[styles.unitSelectorText, { color: colors.text }]}>{dosageUnit}</ThemedText>
|
||
<Ionicons name="chevron-down" size={16} color={colors.textSecondary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.inputGroup}>
|
||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>类型</ThemedText>
|
||
<View style={styles.formGrid}>
|
||
{FORM_OPTIONS.map((option) => {
|
||
const active = selectedForm === option.id;
|
||
return (
|
||
<TouchableOpacity
|
||
key={option.id}
|
||
style={[
|
||
styles.formOption,
|
||
{
|
||
borderColor: active ? colors.primary : softBorderColor,
|
||
backgroundColor: active ? `${colors.primary}10` : colors.surface,
|
||
},
|
||
]}
|
||
onPress={() => setSelectedForm(option.id)}
|
||
activeOpacity={0.9}
|
||
>
|
||
<View style={[styles.formIconBadge, { backgroundColor: active ? colors.primary : fadedBorderFill }]}>
|
||
<MaterialCommunityIcons
|
||
name={option.icon}
|
||
size={18}
|
||
color={active ? colors.onPrimary : colors.textSecondary}
|
||
/>
|
||
</View>
|
||
<ThemedText style={[styles.formLabel, { color: colors.text }]}>{option.label}</ThemedText>
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 2:
|
||
return (
|
||
<View style={styles.stepSection}>
|
||
|
||
<View style={styles.inputGroup}>
|
||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>每日次数</ThemedText>
|
||
<TouchableOpacity
|
||
activeOpacity={0.85}
|
||
onPress={openTimesPicker}
|
||
style={[
|
||
styles.frequencyRow,
|
||
{
|
||
borderColor: softBorderColor,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
>
|
||
<ThemedText style={[styles.frequencyValue, { color: colors.text }]}>{`${timesPerDay} 次/日`}</ThemedText>
|
||
<Ionicons name="chevron-down" size={18} color={colors.textSecondary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 3:
|
||
return (
|
||
<View style={styles.stepSection}>
|
||
<View style={styles.inputGroup}>
|
||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>每日提醒时间</ThemedText>
|
||
<View style={styles.timeList}>
|
||
{medicationTimes.map((time, index) => (
|
||
<View
|
||
key={`${time}-${index}`}
|
||
style={[
|
||
styles.timeItem,
|
||
{
|
||
borderColor: softBorderColor,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
>
|
||
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
|
||
<Ionicons name="time" size={18} color={colors.primary} />
|
||
<ThemedText style={[styles.timeText, { color: colors.text }]}>{time}</ThemedText>
|
||
</TouchableOpacity>
|
||
<Pressable onPress={() => removeTime(index)} disabled={medicationTimes.length === 1} hitSlop={12}>
|
||
<Ionicons
|
||
name="close-circle"
|
||
size={18}
|
||
color={medicationTimes.length === 1 ? softBorderColor : colors.textSecondary}
|
||
/>
|
||
</Pressable>
|
||
</View>
|
||
))}
|
||
<TouchableOpacity style={[styles.addTimeButton, { borderColor: colors.primary }]} onPress={() => openTimePicker()}>
|
||
<Ionicons name="add" size={16} color={colors.primary} />
|
||
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}>添加时间</ThemedText>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
case 4:
|
||
return (
|
||
<View style={styles.stepSection}>
|
||
<View style={styles.inputGroup}>
|
||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>备注</ThemedText>
|
||
<View style={styles.noteInputWrapper}>
|
||
<TextInput
|
||
multiline
|
||
numberOfLines={4}
|
||
value={note}
|
||
onChangeText={setNote}
|
||
placeholder="记录注意事项、医生叮嘱或自定义提醒"
|
||
placeholderTextColor={colors.textMuted}
|
||
style={[
|
||
styles.noteInput,
|
||
{
|
||
borderColor: dictationActive ? colors.primary : softBorderColor,
|
||
backgroundColor: colors.surface,
|
||
color: colors.text,
|
||
},
|
||
]}
|
||
/>
|
||
{isDictationSupported && (
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.noteVoiceButton,
|
||
{
|
||
borderColor: dictationActive ? colors.primary : softBorderColor,
|
||
backgroundColor: dictationActive ? colors.primary : colors.surface,
|
||
},
|
||
]}
|
||
activeOpacity={0.85}
|
||
onPress={handleDictationPress}
|
||
disabled={dictationLoading}
|
||
>
|
||
{dictationLoading ? (
|
||
<ActivityIndicator size="small" color={dictationActive ? colors.background : colors.primary} />
|
||
) : (
|
||
<Ionicons
|
||
name={dictationActive ? 'mic' : 'mic-outline'}
|
||
size={18}
|
||
color={dictationActive ? colors.background : colors.textSecondary}
|
||
/>
|
||
)}
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const showDateField = currentStep === 2;
|
||
|
||
return (
|
||
<View style={[styles.screen, { 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);
|
||
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,
|
||
},
|
||
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',
|
||
},
|
||
});
|