新增药品详情页面,支持查看药品信息、编辑备注、切换提醒状态和删除药品 - 创建动态路由页面 /medications/[medicationId].tsx 展示药品详细信息 - 添加语音输入备注功能,支持 iOS 语音识别 - 实现药品删除确认对话框和删除操作 - 优化药品卡片点击跳转详情页面的交互 - 添加删除操作的加载状态和错误处理 - 改进药品管理页面的开关状态显示和加载指示器
1037 lines
30 KiB
TypeScript
1037 lines
30 KiB
TypeScript
import { ThemedText } from '@/components/ThemedText';
|
||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import { getMedicationById, getMedicationRecords } from '@/services/medications';
|
||
import {
|
||
deleteMedicationAction,
|
||
fetchMedications,
|
||
selectMedications,
|
||
updateMedicationAction,
|
||
} from '@/store/medicationsSlice';
|
||
import type { Medication } from '@/types/medication';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import Voice from '@react-native-voice/voice';
|
||
import dayjs from 'dayjs';
|
||
import { Image } from 'expo-image';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Keyboard,
|
||
Modal,
|
||
Platform,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
const FORM_LABELS: Record<Medication['form'], string> = {
|
||
capsule: '胶囊',
|
||
pill: '药片',
|
||
injection: '注射',
|
||
spray: '喷雾',
|
||
drop: '滴剂',
|
||
syrup: '糖浆',
|
||
other: '其他',
|
||
};
|
||
|
||
const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png');
|
||
|
||
type RecordsSummary = {
|
||
takenCount: number;
|
||
startedDays: number | null;
|
||
};
|
||
|
||
export default function MedicationDetailScreen() {
|
||
const params = useLocalSearchParams<{ medicationId?: string }>();
|
||
const medicationId = Array.isArray(params.medicationId)
|
||
? params.medicationId[0]
|
||
: params.medicationId;
|
||
const dispatch = useAppDispatch();
|
||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||
const colors = Colors[scheme];
|
||
const insets = useSafeAreaInsets();
|
||
const router = useRouter();
|
||
|
||
const medications = useAppSelector(selectMedications);
|
||
const medicationFromStore = medications.find((item) => item.id === medicationId);
|
||
|
||
const [medication, setMedication] = useState<Medication | null>(medicationFromStore ?? null);
|
||
const [loading, setLoading] = useState(!medicationFromStore);
|
||
const [summary, setSummary] = useState<RecordsSummary>({
|
||
takenCount: 0,
|
||
startedDays: null,
|
||
});
|
||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||
const [updatePending, setUpdatePending] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [noteModalVisible, setNoteModalVisible] = useState(false);
|
||
const [noteDraft, setNoteDraft] = useState(medication?.note ?? '');
|
||
const [noteSaving, setNoteSaving] = useState(false);
|
||
const [dictationActive, setDictationActive] = useState(false);
|
||
const [dictationLoading, setDictationLoading] = useState(false);
|
||
const isDictationSupported = Platform.OS === 'ios';
|
||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||
const [deleteSheetVisible, setDeleteSheetVisible] = useState(false);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!medicationFromStore) {
|
||
dispatch(fetchMedications());
|
||
}
|
||
}, [dispatch, medicationFromStore]);
|
||
|
||
useEffect(() => {
|
||
if (medicationFromStore) {
|
||
setMedication(medicationFromStore);
|
||
setLoading(false);
|
||
}
|
||
}, [medicationFromStore]);
|
||
|
||
useEffect(() => {
|
||
setNoteDraft(medication?.note ?? '');
|
||
}, [medication?.note]);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
let abortController = new AbortController();
|
||
|
||
console.log('[MEDICATION_DETAIL] useEffect triggered', {
|
||
medicationId,
|
||
hasMedicationFromStore: !!medicationFromStore,
|
||
deleteLoading
|
||
});
|
||
|
||
// 如果正在删除操作中,不执行任何操作
|
||
if (deleteLoading) {
|
||
console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect');
|
||
return () => {
|
||
isMounted = false;
|
||
abortController.abort();
|
||
};
|
||
}
|
||
|
||
if (!medicationId || medicationFromStore) {
|
||
console.log('[MEDICATION_DETAIL] Early return from useEffect', {
|
||
hasMedicationId: !!medicationId,
|
||
hasMedicationFromStore: !!medicationFromStore
|
||
});
|
||
return () => {
|
||
isMounted = false;
|
||
abortController.abort();
|
||
};
|
||
}
|
||
|
||
console.log('[MEDICATION_DETAIL] Starting API call for medication', medicationId);
|
||
setLoading(true);
|
||
getMedicationById(medicationId)
|
||
.then((data) => {
|
||
if (!isMounted || abortController.signal.aborted) return;
|
||
console.log('[MEDICATION_DETAIL] API call successful', data);
|
||
setMedication(data);
|
||
setError(null);
|
||
})
|
||
.catch((err) => {
|
||
if (abortController.signal.aborted) return;
|
||
console.error('加载药品详情失败', err);
|
||
console.log('[MEDICATION_DETAIL] API call failed for medication', medicationId, err);
|
||
if (isMounted) {
|
||
setError('暂时无法获取该药品的信息,请稍后重试。');
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (isMounted && !abortController.signal.aborted) {
|
||
setLoading(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
abortController.abort();
|
||
};
|
||
}, [medicationId, medicationFromStore, deleteLoading]);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
if (!medicationId) {
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}
|
||
|
||
setSummaryLoading(true);
|
||
getMedicationRecords({ medicationId })
|
||
.then((records) => {
|
||
if (!isMounted) return;
|
||
const takenCount = records.filter((record) => record.status === 'taken').length;
|
||
const earliestRecord = records.reduce<Date | null>((earliest, record) => {
|
||
const current = new Date(record.scheduledTime);
|
||
if (!earliest || current < earliest) {
|
||
return current;
|
||
}
|
||
return earliest;
|
||
}, null);
|
||
const startedDaysRaw = earliestRecord
|
||
? dayjs().diff(dayjs(earliestRecord), 'day')
|
||
: medication
|
||
? dayjs().diff(dayjs(medication.startDate), 'day')
|
||
: null;
|
||
const startedDays = typeof startedDaysRaw === 'number'
|
||
? Math.max(startedDaysRaw, 0)
|
||
: null;
|
||
setSummary({
|
||
takenCount,
|
||
startedDays: startedDays ?? null,
|
||
});
|
||
})
|
||
.catch((err) => {
|
||
console.error('加载服药记录失败', err);
|
||
})
|
||
.finally(() => {
|
||
if (isMounted) {
|
||
setSummaryLoading(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, [medicationId, medication]);
|
||
|
||
const appendDictationResult = useCallback((text: string) => {
|
||
const clean = text.trim();
|
||
if (!clean) return;
|
||
setNoteDraft((prev) => {
|
||
if (!prev) return clean;
|
||
return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`;
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isDictationSupported || !noteModalVisible) {
|
||
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_DETAIL] voice error', error);
|
||
setDictationActive(false);
|
||
setDictationLoading(false);
|
||
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
|
||
};
|
||
|
||
return () => {
|
||
Voice.destroy()
|
||
.then(() => {
|
||
Voice.removeAllListeners();
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
}, [appendDictationResult, isDictationSupported, noteModalVisible]);
|
||
|
||
useEffect(() => {
|
||
if (!noteModalVisible) {
|
||
setKeyboardHeight(0);
|
||
return;
|
||
}
|
||
|
||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||
|
||
const handleShow = (event: any) => {
|
||
const height = event?.endCoordinates?.height ?? 0;
|
||
setKeyboardHeight(height);
|
||
};
|
||
const handleHide = () => setKeyboardHeight(0);
|
||
|
||
const showSub = Keyboard.addListener(showEvent, handleShow);
|
||
const hideSub = Keyboard.addListener(hideEvent, handleHide);
|
||
|
||
return () => {
|
||
showSub.remove();
|
||
hideSub.remove();
|
||
};
|
||
}, [noteModalVisible]);
|
||
|
||
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 {
|
||
// ignore if not recording
|
||
}
|
||
await Voice.start('zh-CN');
|
||
} catch (error) {
|
||
console.log('[MEDICATION_DETAIL] unable to start dictation', error);
|
||
setDictationLoading(false);
|
||
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
|
||
}
|
||
}, [dictationActive, dictationLoading, isDictationSupported]);
|
||
|
||
const closeNoteModal = useCallback(() => {
|
||
setNoteModalVisible(false);
|
||
if (dictationActive) {
|
||
Voice.stop().catch(() => {});
|
||
}
|
||
setDictationActive(false);
|
||
setDictationLoading(false);
|
||
setKeyboardHeight(0);
|
||
}, [dictationActive]);
|
||
|
||
const handleToggleMedication = async (nextValue: boolean) => {
|
||
if (!medication || updatePending) return;
|
||
|
||
try {
|
||
setUpdatePending(true);
|
||
const updated = await dispatch(
|
||
updateMedicationAction({
|
||
id: medication.id,
|
||
isActive: nextValue,
|
||
})
|
||
).unwrap();
|
||
setMedication(updated);
|
||
} catch (err) {
|
||
console.error('切换药品状态失败', err);
|
||
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
|
||
} finally {
|
||
setUpdatePending(false);
|
||
}
|
||
};
|
||
|
||
const formLabel = medication ? FORM_LABELS[medication.form] : '';
|
||
const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--';
|
||
const startDateLabel = medication
|
||
? dayjs(medication.startDate).format('YYYY年M月D日')
|
||
: '--';
|
||
const reminderTimes = medication?.medicationTimes?.length
|
||
? medication.medicationTimes.join('、')
|
||
: '尚未设置';
|
||
const frequencyLabel = useMemo(() => {
|
||
if (!medication) return '--';
|
||
switch (medication.repeatPattern) {
|
||
case 'daily':
|
||
return `每日 ${medication.timesPerDay} 次`;
|
||
case 'weekly':
|
||
return `每周 ${medication.timesPerDay} 次`;
|
||
default:
|
||
return `自定义 · ${medication.timesPerDay} 次/日`;
|
||
}
|
||
}, [medication]);
|
||
|
||
const handleOpenNoteModal = useCallback(() => {
|
||
setNoteDraft(medication?.note ?? '');
|
||
setNoteModalVisible(true);
|
||
}, [medication?.note]);
|
||
|
||
const handleSaveNote = useCallback(async () => {
|
||
if (!medication) return;
|
||
const trimmed = noteDraft.trim();
|
||
setNoteSaving(true);
|
||
try {
|
||
const updated = await dispatch(
|
||
updateMedicationAction({
|
||
id: medication.id,
|
||
note: trimmed || undefined,
|
||
})
|
||
).unwrap();
|
||
setMedication(updated);
|
||
closeNoteModal();
|
||
} catch (err) {
|
||
console.error('保存备注失败', err);
|
||
Alert.alert('保存失败', '提交备注时出现问题,请稍后重试。');
|
||
} finally {
|
||
setNoteSaving(false);
|
||
}
|
||
}, [closeNoteModal, dispatch, medication, noteDraft]);
|
||
|
||
const statusLabel = medication?.isActive ? '提醒已开启' : '提醒已关闭';
|
||
const noteText = medication?.note?.trim() ? medication.note : '暂无备注信息';
|
||
const dayStreakText =
|
||
typeof summary.startedDays === 'number'
|
||
? `已坚持 ${summary.startedDays} 天`
|
||
: medication
|
||
? `开始于 ${dayjs(medication.startDate).format('YYYY年M月D日')}`
|
||
: '暂无开始日期';
|
||
|
||
const handleDeleteMedication = useCallback(async () => {
|
||
if (!medication || deleteLoading) {
|
||
console.log('[MEDICATION_DETAIL] Delete aborted', {
|
||
hasMedication: !!medication,
|
||
deleteLoading
|
||
});
|
||
return;
|
||
}
|
||
console.log('[MEDICATION_DETAIL] Starting delete operation for medication', medication.id);
|
||
try {
|
||
setDeleteLoading(true);
|
||
setDeleteSheetVisible(false); // 立即关闭确认对话框
|
||
await dispatch(deleteMedicationAction(medication.id)).unwrap();
|
||
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
|
||
router.back();
|
||
} catch (err) {
|
||
console.error('删除药品失败', err);
|
||
Alert.alert('删除失败', '移除该药品时出现问题,请稍后再试。');
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
}
|
||
}, [deleteLoading, dispatch, medication, router]);
|
||
|
||
if (!medicationId) {
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
|
||
<HeaderBar title="药品详情" variant="minimal" />
|
||
<View style={[styles.centered, { paddingTop: insets.top + 72, paddingHorizontal: 24 }]}>
|
||
<ThemedText style={styles.emptyTitle}>未找到药品信息</ThemedText>
|
||
<ThemedText style={styles.emptySubtitle}>请从用药列表重新进入此页面。</ThemedText>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const isLoadingState = loading && !medication;
|
||
const contentBottomPadding = Math.max(insets.bottom, 16) + 140;
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis}]}>
|
||
<HeaderBar title="药品详情" variant="minimal" transparent />
|
||
{isLoadingState ? (
|
||
<View style={[styles.centered, { paddingTop: insets.top + 48 }]}>
|
||
<ActivityIndicator color={colors.primary} />
|
||
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>正在载入...</Text>
|
||
</View>
|
||
) : error ? (
|
||
<View style={[styles.centered, { paddingHorizontal: 24, paddingTop: insets.top + 72 }]}>
|
||
<ThemedText style={styles.emptyTitle}>{error}</ThemedText>
|
||
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||
请检查网络后重试,或返回上一页。
|
||
</ThemedText>
|
||
</View>
|
||
) : medication ? (
|
||
<ScrollView
|
||
contentContainerStyle={[
|
||
styles.content,
|
||
{
|
||
paddingTop: insets.top + 48,
|
||
paddingBottom: contentBottomPadding,
|
||
},
|
||
]}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
<View style={[styles.heroCard, { backgroundColor: colors.surface }]}>
|
||
<View style={styles.heroInfo}>
|
||
<View style={styles.heroImageWrapper}>
|
||
<Image
|
||
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
|
||
style={styles.heroImage}
|
||
contentFit="cover"
|
||
/>
|
||
</View>
|
||
<View>
|
||
<Text style={[styles.heroTitle, { color: colors.text }]}>{medication.name}</Text>
|
||
<Text style={[styles.heroMeta, { color: colors.textSecondary }]}>
|
||
{dosageLabel} · {formLabel}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.heroToggle}>
|
||
<Switch
|
||
value={medication.isActive}
|
||
onValueChange={handleToggleMedication}
|
||
disabled={updatePending}
|
||
trackColor={{ false: '#D9D9D9', true: colors.primary }}
|
||
thumbColor="#fff"
|
||
ios_backgroundColor="#D9D9D9"
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
<Section title="服药计划" color={colors.text}>
|
||
<View style={styles.row}>
|
||
<InfoCard
|
||
label="开始日期"
|
||
value={startDateLabel}
|
||
icon="calendar-outline"
|
||
colors={colors}
|
||
/>
|
||
<InfoCard
|
||
label="时间"
|
||
value={reminderTimes}
|
||
icon="time-outline"
|
||
colors={colors}
|
||
/>
|
||
</View>
|
||
<View style={[styles.fullCard, { backgroundColor: colors.surface }]}>
|
||
<View style={styles.fullCardLeading}>
|
||
<Ionicons name="repeat-outline" size={18} color={colors.primary} />
|
||
<Text style={[styles.fullCardLabel, { color: colors.text }]}>频率</Text>
|
||
</View>
|
||
<View style={styles.fullCardTrailing}>
|
||
<Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text>
|
||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||
</View>
|
||
</View>
|
||
</Section>
|
||
|
||
<Section title="剂量与形式" color={colors.text}>
|
||
<View style={styles.row}>
|
||
<InfoCard label="每次剂量" value={dosageLabel} icon="medkit-outline" colors={colors} />
|
||
<InfoCard label="剂型" value={formLabel} icon="cube-outline" colors={colors} />
|
||
</View>
|
||
</Section>
|
||
|
||
<Section title="备注" color={colors.text}>
|
||
<TouchableOpacity
|
||
style={[styles.noteCard, { backgroundColor: colors.surface }]}
|
||
activeOpacity={0.92}
|
||
onPress={handleOpenNoteModal}
|
||
>
|
||
<Ionicons name="document-text-outline" size={20} color={colors.primary} />
|
||
<View style={styles.noteBody}>
|
||
<Text style={[styles.noteLabel, { color: colors.text }]}>药品备注</Text>
|
||
<Text
|
||
style={[
|
||
styles.noteValue,
|
||
{ color: medication.note ? colors.text : colors.textMuted },
|
||
]}
|
||
>
|
||
{noteText}
|
||
</Text>
|
||
</View>
|
||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||
</TouchableOpacity>
|
||
</Section>
|
||
|
||
<Section title="服药概览" color={colors.text}>
|
||
<View style={[styles.summaryCard, { backgroundColor: colors.surface }]}>
|
||
<View style={styles.summaryIcon}>
|
||
<Ionicons name="tablet-portrait-outline" size={22} color={colors.primary} />
|
||
</View>
|
||
<View style={styles.summaryBody}>
|
||
<Text style={[styles.summaryHighlight, { color: colors.text }]}>
|
||
{summaryLoading ? '统计中...' : `累计服药 ${summary.takenCount} 次`}
|
||
</Text>
|
||
<Text style={[styles.summaryMeta, { color: colors.textSecondary }]}>
|
||
{summaryLoading ? '正在计算坚持天数' : dayStreakText}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
</Section>
|
||
</ScrollView>
|
||
) : null}
|
||
|
||
{medication ? (
|
||
<View
|
||
style={[
|
||
styles.footerBar,
|
||
{
|
||
paddingBottom: Math.max(insets.bottom, 18),
|
||
backgroundColor: colors.pageBackgroundEmphasis,
|
||
},
|
||
]}
|
||
>
|
||
<TouchableOpacity
|
||
style={styles.deleteButton}
|
||
activeOpacity={0.9}
|
||
onPress={() => setDeleteSheetVisible(true)}
|
||
>
|
||
<Ionicons name='trash-outline' size={18} color='#fff' />
|
||
<Text style={styles.deleteButtonText}>删除该药品</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
) : null}
|
||
|
||
<Modal
|
||
transparent
|
||
animationType="fade"
|
||
visible={noteModalVisible}
|
||
onRequestClose={closeNoteModal}
|
||
>
|
||
<View style={styles.modalOverlay}>
|
||
<TouchableOpacity style={styles.modalBackdrop} activeOpacity={1} onPress={closeNoteModal} />
|
||
<View
|
||
style={[
|
||
styles.modalContainer,
|
||
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 },
|
||
]}
|
||
>
|
||
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
|
||
<View style={styles.modalHandle} />
|
||
<View style={styles.modalHeader}>
|
||
<Text style={[styles.modalTitle, { color: colors.text }]}>编辑备注</Text>
|
||
<TouchableOpacity onPress={closeNoteModal} hitSlop={12}>
|
||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<View
|
||
style={[
|
||
styles.noteEditorWrapper,
|
||
{
|
||
borderColor: dictationActive ? colors.primary : `${colors.border}80`,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
>
|
||
<TextInput
|
||
multiline
|
||
numberOfLines={6}
|
||
value={noteDraft}
|
||
onChangeText={setNoteDraft}
|
||
placeholder="记录注意事项、医生叮嘱或自定义提醒"
|
||
placeholderTextColor={colors.textMuted}
|
||
style={[styles.noteEditorInput, { color: colors.text }]}
|
||
textAlignVertical="center"
|
||
/>
|
||
{isDictationSupported && (
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.voiceButton,
|
||
{
|
||
backgroundColor: dictationActive ? colors.primary : 'transparent',
|
||
borderColor: dictationActive ? colors.primary : `${colors.border}80`,
|
||
},
|
||
]}
|
||
onPress={handleDictationPress}
|
||
activeOpacity={0.85}
|
||
disabled={dictationLoading}
|
||
>
|
||
{dictationLoading ? (
|
||
<ActivityIndicator size="small" color={dictationActive ? colors.onPrimary : colors.primary} />
|
||
) : (
|
||
<Ionicons
|
||
name={dictationActive ? 'mic' : 'mic-outline'}
|
||
size={18}
|
||
color={dictationActive ? colors.onPrimary : colors.textSecondary}
|
||
/>
|
||
)}
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
{!isDictationSupported && (
|
||
<Text style={[styles.voiceHint, { color: colors.textMuted }]}>
|
||
当前设备暂不支持语音转文字,可直接输入备注
|
||
</Text>
|
||
)}
|
||
<View style={styles.modalActionContainer}>
|
||
<TouchableOpacity
|
||
style={[
|
||
styles.modalActionPrimary,
|
||
{
|
||
backgroundColor: colors.primary,
|
||
shadowColor: colors.primary,
|
||
},
|
||
]}
|
||
onPress={handleSaveNote}
|
||
activeOpacity={0.9}
|
||
disabled={noteSaving}
|
||
>
|
||
{noteSaving ? (
|
||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||
) : (
|
||
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>保存</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
{medication ? (
|
||
<ConfirmationSheet
|
||
visible={deleteSheetVisible}
|
||
onClose={() => setDeleteSheetVisible(false)}
|
||
onConfirm={handleDeleteMedication}
|
||
title={`删除 ${medication.name}?`}
|
||
description="删除后将清除与该药品相关的提醒与历史记录,且无法恢复。"
|
||
confirmText="删除"
|
||
cancelText="取消"
|
||
destructive
|
||
loading={deleteLoading}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const Section = ({
|
||
title,
|
||
children,
|
||
color,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
color: string;
|
||
}) => {
|
||
return (
|
||
<View style={styles.section}>
|
||
<Text style={[styles.sectionTitle, { color }]}>{title}</Text>
|
||
{children}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const InfoCard = ({
|
||
label,
|
||
value,
|
||
icon,
|
||
colors,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
icon: keyof typeof Ionicons.glyphMap;
|
||
colors: (typeof Colors)[keyof typeof Colors];
|
||
}) => {
|
||
return (
|
||
<View style={[styles.infoCard, { backgroundColor: colors.surface }]}>
|
||
<View style={styles.infoCardIcon}>
|
||
<Ionicons name={icon} size={16} color="#4C6EF5" />
|
||
</View>
|
||
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
|
||
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
position: 'relative',
|
||
},
|
||
content: {
|
||
paddingHorizontal: 20,
|
||
gap: 24,
|
||
},
|
||
centered: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12,
|
||
},
|
||
loadingText: {
|
||
marginTop: 8,
|
||
fontSize: 14,
|
||
},
|
||
emptyTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '600',
|
||
textAlign: 'center',
|
||
},
|
||
emptySubtitle: {
|
||
fontSize: 14,
|
||
textAlign: 'center',
|
||
},
|
||
heroCard: {
|
||
borderRadius: 28,
|
||
padding: 20,
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.05,
|
||
shadowRadius: 12,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
elevation: 3,
|
||
gap: 12,
|
||
},
|
||
heroInfo: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 14,
|
||
flex: 1,
|
||
},
|
||
heroImageWrapper: {
|
||
width: 64,
|
||
height: 64,
|
||
borderRadius: 20,
|
||
backgroundColor: '#F2F2F2',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
heroImage: {
|
||
width: '80%',
|
||
height: '80%',
|
||
},
|
||
heroTitle: {
|
||
fontSize: 20,
|
||
fontWeight: '700',
|
||
},
|
||
heroMeta: {
|
||
marginTop: 4,
|
||
fontSize: 13,
|
||
fontWeight: '500',
|
||
},
|
||
heroToggle: {
|
||
alignItems: 'flex-end',
|
||
gap: 6,
|
||
},
|
||
section: {
|
||
gap: 12,
|
||
},
|
||
sectionTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
gap: 12,
|
||
},
|
||
infoCard: {
|
||
flex: 1,
|
||
borderRadius: 20,
|
||
padding: 16,
|
||
backgroundColor: '#fff',
|
||
gap: 6,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.04,
|
||
shadowRadius: 8,
|
||
shadowOffset: { width: 0, height: 4 },
|
||
elevation: 2,
|
||
},
|
||
infoCardIcon: {
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 14,
|
||
backgroundColor: '#EEF1FF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
infoCardLabel: {
|
||
fontSize: 13,
|
||
color: '#6B7280',
|
||
marginTop: 8,
|
||
},
|
||
infoCardValue: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
color: '#1F2933',
|
||
},
|
||
fullCard: {
|
||
borderRadius: 22,
|
||
padding: 18,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
},
|
||
fullCardLeading: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
},
|
||
fullCardLabel: {
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
},
|
||
fullCardTrailing: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
},
|
||
fullCardValue: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
noteCard: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 14,
|
||
borderRadius: 24,
|
||
paddingHorizontal: 18,
|
||
paddingVertical: 16,
|
||
},
|
||
noteBody: {
|
||
flex: 1,
|
||
gap: 4,
|
||
},
|
||
noteLabel: {
|
||
fontSize: 14,
|
||
fontWeight: '600',
|
||
},
|
||
noteValue: {
|
||
fontSize: 14,
|
||
lineHeight: 20,
|
||
},
|
||
summaryCard: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
borderRadius: 24,
|
||
padding: 18,
|
||
gap: 16,
|
||
},
|
||
summaryIcon: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
backgroundColor: '#EEF1FF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
summaryBody: {
|
||
flex: 1,
|
||
gap: 4,
|
||
},
|
||
summaryHighlight: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
},
|
||
summaryMeta: {
|
||
fontSize: 14,
|
||
},
|
||
modalOverlay: {
|
||
flex: 1,
|
||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||
justifyContent: 'flex-end',
|
||
},
|
||
modalBackdrop: {
|
||
flex: 1,
|
||
},
|
||
modalContainer: {
|
||
width: '100%',
|
||
paddingHorizontal: 20,
|
||
},
|
||
modalCard: {
|
||
borderTopLeftRadius: 28,
|
||
borderTopRightRadius: 28,
|
||
paddingHorizontal: 20,
|
||
paddingTop: 20,
|
||
paddingBottom: 32,
|
||
gap: 16,
|
||
},
|
||
modalHandle: {
|
||
width: 60,
|
||
height: 5,
|
||
borderRadius: 2.5,
|
||
backgroundColor: 'rgba(0,0,0,0.12)',
|
||
alignSelf: 'center',
|
||
marginBottom: 12,
|
||
},
|
||
modalHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
},
|
||
modalTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
},
|
||
noteEditorWrapper: {
|
||
borderWidth: 1,
|
||
borderRadius: 24,
|
||
paddingHorizontal: 18,
|
||
paddingVertical: 24,
|
||
minHeight: 120,
|
||
paddingRight: 70,
|
||
shadowColor: '#000',
|
||
shadowOpacity: 0.06,
|
||
shadowRadius: 12,
|
||
shadowOffset: { width: 0, height: 6 },
|
||
elevation: 3,
|
||
},
|
||
noteEditorInput: {
|
||
minHeight: 50,
|
||
fontSize: 15,
|
||
lineHeight: 22,
|
||
},
|
||
voiceButton: {
|
||
position: 'absolute',
|
||
right: 16,
|
||
top: 16,
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
borderWidth: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
voiceHint: {
|
||
fontSize: 12,
|
||
},
|
||
modalActionGhostText: {
|
||
fontSize: 16,
|
||
fontWeight: '600',
|
||
},
|
||
modalActionPrimary: {
|
||
borderRadius: 22,
|
||
height: 52,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginHorizontal: 8,
|
||
shadowOpacity: 0.25,
|
||
shadowRadius: 12,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
elevation: 4,
|
||
},
|
||
modalActionPrimaryText: {
|
||
fontSize: 17,
|
||
fontWeight: '700',
|
||
},
|
||
modalActionContainer: {
|
||
marginTop: 8,
|
||
},
|
||
footerBar: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
paddingHorizontal: 20,
|
||
paddingTop: 16,
|
||
borderTopWidth: StyleSheet.hairlineWidth,
|
||
borderTopColor: 'rgba(15,23,42,0.06)',
|
||
},
|
||
deleteButton: {
|
||
height: 56,
|
||
borderRadius: 24,
|
||
backgroundColor: '#EF4444',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
shadowColor: 'rgba(239,68,68,0.4)',
|
||
shadowOffset: { width: 0, height: 10 },
|
||
shadowOpacity: 1,
|
||
shadowRadius: 20,
|
||
elevation: 6,
|
||
},
|
||
deleteButtonText: {
|
||
fontSize: 17,
|
||
fontWeight: '700',
|
||
color: '#fff',
|
||
},
|
||
});
|