feat(i18n): 实现应用国际化支持,添加中英文翻译

- 为所有UI组件添加国际化支持,替换硬编码文本
- 新增useI18n钩子函数统一管理翻译
- 完善中英文翻译资源,覆盖统计、用药、通知设置等模块
- 优化Tab布局使用翻译键值替代静态文本
- 更新药品管理、个人资料编辑等页面的多语言支持
This commit is contained in:
richarjiang
2025-11-13 11:09:55 +08:00
parent 416d144387
commit 2dca3253e6
21 changed files with 1669 additions and 366 deletions

View File

@@ -3,6 +3,7 @@ import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-ef
import * as Haptics from 'expo-haptics';
import { Tabs, usePathname } from 'expo-router';
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
import { useTranslation } from 'react-i18next';
import React from 'react';
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
@@ -16,18 +17,19 @@ import { useColorScheme } from '@/hooks/useColorScheme';
// Tab configuration
type TabConfig = {
icon: string;
title: string;
titleKey: string;
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', title: '健康' },
medications: { icon: 'pills.fill', title: '用药' },
fasting: { icon: 'timer', title: '断食' },
challenges: { icon: 'trophy.fill', title: '挑战' },
personal: { icon: 'person.fill', title: '个人' },
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
};
export default function TabLayout() {
const { t } = useTranslation();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const pathname = usePathname();
@@ -96,7 +98,7 @@ export default function TabLayout() {
}}
numberOfLines={1}
>
{tabConfig.title}
{t(tabConfig.titleKey)}
</Text>
)}
</View>
@@ -175,24 +177,24 @@ export default function TabLayout() {
if (glassEffectAvailable) {
return <NativeTabs>
<NativeTabs.Trigger name="statistics">
<Label></Label>
<Label>{t('statistics.tabs.health')}</Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="medications">
<Icon sf="pills.fill" drawable="custom_android_drawable" />
<Label></Label>
<Label>{t('statistics.tabs.medications')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label></Label>
<Label>{t('statistics.tabs.fasting')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="challenges">
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
<Label></Label>
<Label>{t('statistics.tabs.challenges')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="personal">
<Icon sf="person.fill" drawable="custom_settings_drawable" />
<Label></Label>
<Label>{t('statistics.tabs.personal')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
}
@@ -203,11 +205,11 @@ export default function TabLayout() {
screenOptions={({ route }) => getScreenOptions(route.name)}
>
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
<Tabs.Screen name="medications" options={{ title: '用药' }} />
<Tabs.Screen name="fasting" options={{ title: '断食' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />
<Tabs.Screen name="statistics" options={{ title: t('statistics.tabs.health') }} />
<Tabs.Screen name="medications" options={{ title: t('statistics.tabs.medications') }} />
<Tabs.Screen name="fasting" options={{ title: t('statistics.tabs.fasting') }} />
<Tabs.Screen name="challenges" options={{ title: t('statistics.tabs.challenges') }} />
<Tabs.Screen name="personal" options={{ title: t('statistics.tabs.personal') }} />
</Tabs>
);
}

View File

@@ -1,5 +1,5 @@
import { DateSelector } from '@/components/DateSelector';
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
@@ -17,6 +17,7 @@ import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ScrollView,
StyleSheet,
@@ -32,6 +33,7 @@ type MedicationFilter = 'all' | 'taken' | 'missed';
type ThemeColors = (typeof Colors)[keyof typeof Colors];
export default function MedicationsScreen() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
@@ -147,8 +149,8 @@ export default function MedicationsScreen() {
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
? `今天,${selectedDate.format('M月D日')}`
: selectedDate.format('M月D日 dddd');
? t('medications.dateFormats.today', { date: selectedDate.format('M月D日') })
: t('medications.dateFormats.other', { date: selectedDate.format('M月D日 dddd') });
const emptyState = filteredMedications.length === 0;
@@ -178,9 +180,9 @@ export default function MedicationsScreen() {
>
<View style={styles.header}>
<View>
<ThemedText style={styles.greeting}>{displayName}</ThemedText>
<ThemedText style={styles.greeting}>{t('medications.greeting', { name: displayName })}</ThemedText>
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
{t('medications.welcome')}
</ThemedText>
</View>
<View style={styles.headerActions}>
@@ -239,15 +241,10 @@ export default function MedicationsScreen() {
</View>
<View style={styles.sectionSpacing}>
<ThemedText style={styles.sectionHeader}></ThemedText>
<ThemedText style={styles.sectionHeader}>{t('medications.todayMedications')}</ThemedText>
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
const isActive = activeFilter === filter;
const labelMap: Record<MedicationFilter, string> = {
all: '全部',
taken: '已服用',
missed: '未服用',
};
return (
<TouchableOpacity
key={filter}
@@ -263,7 +260,7 @@ export default function MedicationsScreen() {
{ color: isActive ? colors.onPrimary : colors.textSecondary },
]}
>
{labelMap[filter]}
{t(`medications.filters.${filter}`)}
</ThemedText>
<View
style={[
@@ -295,9 +292,9 @@ export default function MedicationsScreen() {
style={styles.emptyIllustration}
contentFit="cover"
/>
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={styles.emptyTitle}>{t('medications.emptyState.title')}</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
{t('medications.emptyState.subtitle')}
</ThemedText>
</View>
) : (

View File

@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppState,
Image,
@@ -55,6 +56,7 @@ const FloatingCard = ({ children, style }: {
};
export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
@@ -351,7 +353,7 @@ export default function ExploreScreen() {
{/* 右边文字区域 */}
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>Out Live</Text>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
</View>
{/* 开发环境调试按钮 */}
@@ -360,7 +362,7 @@ export default function ExploreScreen() {
<TouchableOpacity
style={styles.debugButton}
onPress={async () => {
console.log('🔧 手动触发后台任务测试...');
console.log('🔧 Manual background task test...');
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}}
>
@@ -370,7 +372,7 @@ export default function ExploreScreen() {
<TouchableOpacity
style={[styles.debugButton, styles.hrvTestButton]}
onPress={async () => {
console.log('🫀 测试HRV数据获取...');
console.log('🫀 Testing HRV data fetch...');
await testHRVDataFetch();
}}
>
@@ -407,7 +409,7 @@ export default function ExploreScreen() {
{/* 身体指标section标题 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View>
{/* 真正瀑布流布局 */}

View File

@@ -3,12 +3,13 @@ import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_LABELS, FORM_OPTIONS } from '@/constants/Medication';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
import { ROUTES } from '@/constants/Routes';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useVipService } from '@/hooks/useVipService';
import { medicationNotificationService } from '@/services/medicationNotifications';
import {
@@ -59,6 +60,7 @@ type RecordsSummary = {
};
export default function MedicationDetailScreen() {
const { t } = useI18n();
const params = useLocalSearchParams<{ medicationId?: string }>();
const medicationId = Array.isArray(params.medicationId)
? params.medicationId[0]
@@ -198,7 +200,7 @@ export default function MedicationDetailScreen() {
console.error('加载药品详情失败', err);
console.log('[MEDICATION_DETAIL] API call failed for medication', medicationId, err);
if (isMounted) {
setError('暂时无法获取该药品的信息,请稍后重试。');
setError(t('medications.detail.error.title'));
}
})
.finally(() => {
@@ -295,7 +297,7 @@ export default function MedicationDetailScreen() {
console.log('[MEDICATION_DETAIL] voice error', error);
setDictationActive(false);
setDictationLoading(false);
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
Alert.alert(t('medications.add.note.voiceError'), t('medications.add.note.voiceErrorMessage'));
};
return () => {
@@ -354,7 +356,7 @@ export default function MedicationDetailScreen() {
} catch (error) {
console.log('[MEDICATION_DETAIL] unable to start dictation', error);
setDictationLoading(false);
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
Alert.alert(t('medications.add.note.voiceStartError'), t('medications.add.note.voiceStartErrorMessage'));
}
}, [dictationActive, dictationLoading, isDictationSupported]);
@@ -400,7 +402,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('切换药品状态失败', err);
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
} finally {
setUpdatePending(false);
}
@@ -430,13 +432,13 @@ export default function MedicationDetailScreen() {
}
} catch (error) {
console.error('停用药物失败', error);
Alert.alert('操作失败', '停用药物时发生问题,请稍后重试。');
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
} finally {
setDeactivateLoading(false);
}
}, [dispatch, medication, deactivateLoading]);
const formLabel = medication ? FORM_LABELS[medication.form] : '';
const formLabel = medication ? t(`medications.manage.formLabels.${medication.form}`) : '';
const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--';
const startDateLabel = medication
? dayjs(medication.startDate).format('YYYY年M月D日')
@@ -454,24 +456,24 @@ export default function MedicationDetailScreen() {
return `${startDate} - ${endDate}`;
} else {
// 没有结束日期,显示长期
return `${startDate} - 长期`;
return `${startDate} - ${t('medications.detail.plan.longTerm')}`;
}
}, [medication]);
}, [medication, t]);
const reminderTimes = medication?.medicationTimes?.length
? medication.medicationTimes.join('、')
: '尚未设置';
: t('medications.manage.reminderNotSet');
const frequencyLabel = useMemo(() => {
if (!medication) return '--';
switch (medication.repeatPattern) {
case 'daily':
return `每日 ${medication.timesPerDay}`;
return `${t('medications.manage.frequency.daily')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
case 'weekly':
return `每周 ${medication.timesPerDay}`;
return `${t('medications.manage.frequency.weekly')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
default:
return `自定义 · ${medication.timesPerDay} 次/日`;
return `${t('medications.manage.frequency.custom')} · ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
}
}, [medication]);
}, [medication, t]);
const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? '');
@@ -493,20 +495,20 @@ export default function MedicationDetailScreen() {
closeNoteModal();
} catch (err) {
console.error('保存备注失败', err);
Alert.alert('保存失败', '提交备注时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.note.saveError.title'), t('medications.detail.note.saveError.message'));
} finally {
setNoteSaving(false);
}
}, [closeNoteModal, dispatch, medication, noteDraft]);
const statusLabel = medication?.isActive ? '提醒已开启' : '提醒已关闭';
const noteText = medication?.note?.trim() ? medication.note : '暂无备注信息';
const statusLabel = medication?.isActive ? t('medications.detail.status.enabled') : t('medications.detail.status.disabled');
const noteText = medication?.note?.trim() ? medication.note : t('medications.detail.note.noNote');
const dayStreakText =
typeof summary.startedDays === 'number'
? `已坚持 ${summary.startedDays}`
? t('medications.detail.overview.startedDays', { days: summary.startedDays })
: medication
? `开始于 ${dayjs(medication.startDate).format('YYYY年M月D日')}`
: '暂无开始日期';
? t('medications.detail.overview.startDate', { date: dayjs(medication.startDate).format('YYYY年M月D日') })
: t('medications.detail.overview.noStartDate');
const handleDeleteMedication = useCallback(async () => {
if (!medication || deleteLoading) {
@@ -534,7 +536,7 @@ export default function MedicationDetailScreen() {
router.back();
} catch (err) {
console.error('删除药品失败', err);
Alert.alert('删除失败', '移除该药品时出现问题,请稍后再试。');
Alert.alert(t('medications.detail.delete.error.title'), t('medications.detail.delete.error.message'));
} finally {
setDeleteLoading(false);
}
@@ -550,21 +552,26 @@ export default function MedicationDetailScreen() {
if (!medication) return;
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
let message = `开始服药日期:${startDate}`;
let message;
if (medication.endDate) {
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
message += `\n结束服药日期${endDate}`;
message = t('medications.detail.plan.periodMessage', {
startDate,
endDateInfo: t('medications.detail.plan.periodMessage', { endDate })
});
} else {
message += `\n服药计划长期服药`;
message = t('medications.detail.plan.periodMessage', {
startDate,
endDateInfo: t('medications.detail.plan.longTermPlan')
});
}
Alert.alert('服药周期', message);
}, [medication]);
Alert.alert(t('medications.detail.sections.plan'), message);
}, [medication, t]);
const handleTimePress = useCallback(() => {
Alert.alert('服药时间', `设置的时间:${reminderTimes}`);
}, [reminderTimes]);
Alert.alert(t('medications.detail.plan.time'), t('medications.detail.plan.timeMessage', { times: reminderTimes }));
}, [reminderTimes, t]);
const handleDosagePress = useCallback(() => {
if (!medication) return;
@@ -621,7 +628,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('更新剂量失败', err);
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
} finally {
setUpdatePending(false);
}
@@ -656,7 +663,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('更新剂型失败', err);
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
} finally {
setUpdatePending(false);
}
@@ -723,27 +730,27 @@ export default function MedicationDetailScreen() {
onError: (error: any) => {
console.error('[MEDICATION] AI 分析失败:', error);
let errorMessage = 'AI 分析失败,请稍后重试';
let errorMessage = t('medications.detail.aiAnalysis.error.message');
// 解析服务端返回的错误信息
if (error?.message) {
if (error.message.includes('[ERROR]')) {
errorMessage = error.message.replace('[ERROR]', '').trim();
} else if (error.message.includes('无权访问')) {
errorMessage = '无权访问此药物';
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error.message.includes('不存在')) {
errorMessage = '药物不存在';
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
} else if (error?.status === 401) {
errorMessage = '请先登录';
errorMessage = t('medications.detail.aiAnalysis.error.unauthorized');
} else if (error?.status === 403) {
errorMessage = '无权访问此药物';
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error?.status === 404) {
errorMessage = '药物不存在';
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
// 使用 Alert 弹窗显示错误
Alert.alert('分析失败', errorMessage);
Alert.alert(t('medications.detail.aiAnalysis.error.title'), errorMessage);
// 清空内容和加载状态
setAiAnalysisContent('');
@@ -756,7 +763,7 @@ export default function MedicationDetailScreen() {
console.error('[MEDICATION] AI 分析异常:', error);
// 使用 Alert 弹窗显示错误
Alert.alert('分析失败', '发起分析请求失败,请检查网络连接');
Alert.alert(t('medications.detail.aiAnalysis.error.title'), t('medications.detail.aiAnalysis.error.networkError'));
// 清空内容和加载状态
setAiAnalysisContent('');
@@ -789,10 +796,10 @@ export default function MedicationDetailScreen() {
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title="药品详情" variant="minimal" transparent />
<HeaderBar title={t('medications.detail.title')} variant="minimal" transparent />
<View style={[styles.centered, { paddingTop: insets.top + 72, paddingHorizontal: 24 }]}>
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={styles.emptySubtitle}></ThemedText>
<ThemedText style={styles.emptyTitle}>{t('medications.detail.notFound.title')}</ThemedText>
<ThemedText style={styles.emptySubtitle}>{t('medications.detail.notFound.subtitle')}</ThemedText>
</View>
</View>
);
@@ -815,17 +822,17 @@ export default function MedicationDetailScreen() {
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title="药品详情" variant="minimal" transparent />
<HeaderBar title={t('medications.detail.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>
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>{t('medications.detail.loading')}</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 }]}>
{t('medications.detail.error.subtitle')}
</ThemedText>
</View>
) : medication ? (
@@ -878,10 +885,10 @@ export default function MedicationDetailScreen() {
</View>
</View>
<Section title="服药计划" color={colors.text}>
<Section title={t('medications.detail.sections.plan')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label="服药周期"
label={t('medications.detail.plan.period')}
value={medicationPeriodLabel}
icon="calendar-outline"
colors={colors}
@@ -889,7 +896,7 @@ export default function MedicationDetailScreen() {
onPress={handleStartDatePress}
/>
<InfoCard
label="用药时间"
label={t('medications.detail.plan.time')}
value={reminderTimes}
icon="time-outline"
colors={colors}
@@ -904,7 +911,7 @@ export default function MedicationDetailScreen() {
>
<View style={styles.fullCardLeading}>
<Ionicons name="repeat-outline" size={18} color={colors.primary} />
<Text style={[styles.fullCardLabel, { color: colors.text }]}></Text>
<Text style={[styles.fullCardLabel, { color: colors.text }]}>{t('medications.detail.plan.frequency')}</Text>
</View>
<View style={styles.fullCardTrailing}>
<Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text>
@@ -913,10 +920,10 @@ export default function MedicationDetailScreen() {
</TouchableOpacity>
</Section>
<Section title="剂量与形式" color={colors.text}>
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label="每次剂量"
label={t('medications.detail.dosage.label')}
value={dosageLabel}
icon="medkit-outline"
colors={colors}
@@ -924,7 +931,7 @@ export default function MedicationDetailScreen() {
onPress={handleDosagePress}
/>
<InfoCard
label="剂型"
label={t('medications.detail.dosage.form')}
value={formLabel}
icon="cube-outline"
colors={colors}
@@ -934,7 +941,7 @@ export default function MedicationDetailScreen() {
</View>
</Section>
<Section title="备注" color={colors.text}>
<Section title={t('medications.detail.sections.note')} color={colors.text}>
<TouchableOpacity
style={[styles.noteCard, { backgroundColor: colors.surface }]}
activeOpacity={0.92}
@@ -942,7 +949,7 @@ export default function MedicationDetailScreen() {
>
<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.noteLabel, { color: colors.text }]}>{t('medications.detail.note.label')}</Text>
<Text
style={[
styles.noteValue,
@@ -956,17 +963,17 @@ export default function MedicationDetailScreen() {
</TouchableOpacity>
</Section>
<Section title="服药概览" color={colors.text}>
<Section title={t('medications.detail.sections.overview')} 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}`}
{summaryLoading ? t('medications.detail.overview.calculating') : t('medications.detail.overview.takenCount', { count: summary.takenCount })}
</Text>
<Text style={[styles.summaryMeta, { color: colors.textSecondary }]}>
{summaryLoading ? '正在计算坚持天数' : dayStreakText}
{summaryLoading ? t('medications.detail.overview.calculatingDays') : dayStreakText}
</Text>
</View>
</View>
@@ -974,13 +981,13 @@ export default function MedicationDetailScreen() {
{/* AI 分析结果展示 - 移动到底部 */}
{(aiAnalysisContent || aiAnalysisLoading) && (
<Section title="AI 用药分析" color={colors.text}>
<Section title={t('medications.detail.sections.aiAnalysis')} color={colors.text}>
<View style={[styles.aiAnalysisCard, { backgroundColor: colors.surface }]}>
{aiAnalysisLoading && !aiAnalysisContent && (
<View style={styles.aiAnalysisLoading}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.aiAnalysisLoadingText, { color: colors.textSecondary }]}>
...
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
</View>
)}
@@ -1102,7 +1109,7 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')}
</Text>
</GlassView>
) : (
@@ -1113,7 +1120,7 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')}
</Text>
</View>
)}
@@ -1161,7 +1168,7 @@ export default function MedicationDetailScreen() {
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}></Text>
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('medications.detail.note.edit')}</Text>
<TouchableOpacity onPress={closeNoteModal} hitSlop={12}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
@@ -1181,7 +1188,7 @@ export default function MedicationDetailScreen() {
numberOfLines={6}
value={noteDraft}
onChangeText={setNoteDraft}
placeholder="记录注意事项、医生叮嘱或自定义提醒"
placeholder={t('medications.detail.note.placeholder')}
placeholderTextColor={colors.textMuted}
style={[styles.noteEditorInput, { color: colors.text }]}
textAlignVertical="center"
@@ -1213,7 +1220,7 @@ export default function MedicationDetailScreen() {
</View>
{!isDictationSupported && (
<Text style={[styles.voiceHint, { color: colors.textMuted }]}>
{t('medications.detail.note.voiceNotSupported')}
</Text>
)}
<View style={styles.modalActionContainer}>
@@ -1232,7 +1239,7 @@ export default function MedicationDetailScreen() {
{noteSaving ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}></Text>
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>{t('medications.detail.note.save')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1253,12 +1260,12 @@ export default function MedicationDetailScreen() {
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{t('medications.detail.dosage.selectDosage')}
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
{t('medications.detail.dosage.dosageValue')}
</ThemedText>
<Picker
selectedValue={dosageValuePicker}
@@ -1277,7 +1284,7 @@ export default function MedicationDetailScreen() {
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
{t('medications.detail.dosage.unit')}
</ThemedText>
<Picker
selectedValue={dosageUnitPicker}
@@ -1301,7 +1308,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
@@ -1309,7 +1316,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
@@ -1328,7 +1335,7 @@ export default function MedicationDetailScreen() {
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{t('medications.detail.dosage.selectForm')}
</ThemedText>
<Picker
selectedValue={formPicker}
@@ -1350,7 +1357,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
@@ -1358,7 +1365,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
@@ -1370,10 +1377,10 @@ export default function MedicationDetailScreen() {
visible={deleteSheetVisible}
onClose={() => setDeleteSheetVisible(false)}
onConfirm={handleDeleteMedication}
title={`删除 ${medication.name}`}
description="删除后将清除与该药品相关的提醒与历史记录,且无法恢复。"
confirmText="删除"
cancelText="取消"
title={t('medications.detail.delete.title', { name: medication.name })}
description={t('medications.detail.delete.description')}
confirmText={t('medications.detail.delete.confirm')}
cancelText={t('medications.detail.delete.cancel')}
destructive
loading={deleteLoading}
/>
@@ -1384,10 +1391,10 @@ export default function MedicationDetailScreen() {
visible={deactivateSheetVisible}
onClose={() => setDeactivateSheetVisible(false)}
onConfirm={handleDeactivateMedication}
title={`停用 ${medication.name}`}
description="停用后,当天已生成的用药计划会一并删除,且无法恢复。"
confirmText="确认停用"
cancelText="取消"
title={t('medications.detail.deactivate.title', { name: medication.name })}
description={t('medications.detail.deactivate.description')}
confirmText={t('medications.detail.deactivate.confirm')}
cancelText={t('medications.detail.deactivate.cancel')}
destructive
loading={deactivateLoading}
/>
@@ -1415,7 +1422,7 @@ export default function MedicationDetailScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
</TouchableOpacity>
</View>
)}

View File

@@ -5,6 +5,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchMedications,
@@ -12,6 +13,7 @@ import {
selectMedicationsLoading,
updateMedicationAction,
} from '@/store/medicationsSlice';
import { selectUserProfile } from '@/store/userSlice';
import type { Medication, MedicationForm } from '@/types/medication';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -33,25 +35,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
type FilterType = 'all' | 'active' | 'inactive';
const FORM_LABELS: Record<MedicationForm, string> = {
capsule: '胶囊',
pill: '药片',
injection: '注射',
spray: '喷雾',
drop: '滴剂',
syrup: '糖浆',
other: '其他',
};
const FILTER_CONFIG: { key: FilterType; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'inactive', label: '已停用' },
];
// 这些常量将在组件内部定义,以便使用翻译函数
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
export default function ManageMedicationsScreen() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors = Colors[theme];
@@ -59,6 +48,7 @@ export default function ManageMedicationsScreen() {
const insets = useSafeAreaInsets();
const medications = useAppSelector(selectMedications);
const loading = useAppSelector(selectMedicationsLoading);
const userProfile = useAppSelector(selectUserProfile);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null);
const [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
@@ -121,7 +111,7 @@ export default function ManageMedicationsScreen() {
).unwrap();
} catch (error) {
console.error('更新药物状态失败', error);
Alert.alert('操作失败', '切换药物状态时发生问题,请稍后重试。');
Alert.alert(t('medications.manage.toggleError.title'), t('medications.manage.toggleError.message'));
} finally {
setPendingMedicationId(null);
}
@@ -144,7 +134,7 @@ export default function ManageMedicationsScreen() {
).unwrap();
} catch (error) {
console.error('停用药物失败', error);
Alert.alert('操作失败', '停用药物时发生问题,请稍后重试。');
Alert.alert(t('medications.manage.deactivate.error.title'), t('medications.manage.deactivate.error.message'));
} finally {
setDeactivateLoading(false);
setMedicationToDeactivate(null);
@@ -153,14 +143,25 @@ export default function ManageMedicationsScreen() {
// 创建独立的药品卡片组件,使用 React.memo 优化渲染
const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => {
// 使用翻译函数获取剂型标签
const FORM_LABELS: Record<MedicationForm, string> = {
capsule: t('medications.manage.formLabels.capsule'),
pill: t('medications.manage.formLabels.pill'),
injection: t('medications.manage.formLabels.injection'),
spray: t('medications.manage.formLabels.spray'),
drop: t('medications.manage.formLabels.drop'),
syrup: t('medications.manage.formLabels.syrup'),
other: t('medications.manage.formLabels.other'),
};
const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim();
const frequencyLabel = `${medication.repeatPattern === 'daily' ? '每日' : medication.repeatPattern === 'weekly' ? '每周' : '自定义'} | ${dosageLabel}`;
const frequencyLabel = `${medication.repeatPattern === 'daily' ? t('medications.manage.frequency.daily') : medication.repeatPattern === 'weekly' ? t('medications.manage.frequency.weekly') : t('medications.manage.frequency.custom')} | ${dosageLabel}`;
const startDateLabel = dayjs(medication.startDate).isValid()
? dayjs(medication.startDate).format('M月D日')
: '未知日期';
: t('medications.manage.unknownDate');
const reminderLabel = medication.medicationTimes?.length
? medication.medicationTimes.join('、')
: `${medication.timesPerDay} 次/日`;
: `${medication.timesPerDay} ${t('medications.manage.cardMeta.reminderNotSet')}`;
return (
<TouchableOpacity
@@ -180,7 +181,7 @@ export default function ManageMedicationsScreen() {
{frequencyLabel}
</ThemedText>
<ThemedText style={[styles.cardMeta, { color: colors.textMuted }]}>
{`开始于 ${startDateLabel} 提醒:${reminderLabel}`}
{t('medications.manage.cardMeta', { date: startDateLabel, reminder: reminderLabel })}
</ThemedText>
</View>
</View>
@@ -250,26 +251,27 @@ export default function ManageMedicationsScreen() {
<View style={styles.decorativeCircle2} />
<HeaderBar
title="药品管理"
title={t('medications.manage.title')}
onBack={() => router.back()}
variant="minimal"
transparent
/>
<View style={{ paddingTop: safeAreaTop }} />
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 32 },
{
paddingTop: safeAreaTop , // HeaderBar高度 + 额外间距
paddingBottom: insets.bottom + 32
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.pageHeader}>
<View>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.title}>{t('medications.greeting', { name: userProfile.name || '朋友' })}</ThemedText>
<ThemedText style={[styles.subtitle, { color: colors.textMuted }]}>
{t('medications.manage.subtitle')}
</ThemedText>
</View>
<TouchableOpacity
@@ -294,7 +296,11 @@ export default function ManageMedicationsScreen() {
</View>
<View style={[styles.segmented, { backgroundColor: colors.surface }]}>
{FILTER_CONFIG.map((filter) => {
{[
{ key: 'all' as FilterType, label: t('medications.manage.filters.all') },
{ key: 'active' as FilterType, label: t('medications.manage.filters.active') },
{ key: 'inactive' as FilterType, label: t('medications.manage.filters.inactive') },
].map((filter) => {
const isActive = filter.key === activeFilter;
return (
<TouchableOpacity
@@ -341,14 +347,14 @@ export default function ManageMedicationsScreen() {
{listLoading ? (
<View style={[styles.loading, { backgroundColor: colors.surface }]}>
<ActivityIndicator color={colors.primary} />
<ThemedText style={styles.loadingText}>...</ThemedText>
<ThemedText style={styles.loadingText}>{t('medications.manage.loading')}</ThemedText>
</View>
) : filteredMedications.length === 0 ? (
<View style={[styles.empty, { backgroundColor: colors.surface }]}>
<Image source={DEFAULT_IMAGE} style={styles.emptyImage} contentFit="contain" />
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={styles.emptyTitle}>{t('medications.manage.empty.title')}</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textSecondary }]}>
{t('medications.manage.empty.subtitle')}
</ThemedText>
</View>
) : (
@@ -365,10 +371,10 @@ export default function ManageMedicationsScreen() {
setMedicationToDeactivate(null);
}}
onConfirm={handleDeactivateMedication}
title={`停用 ${medicationToDeactivate.name}`}
description="停用后,当天已生成的用药计划会一并删除,且无法恢复。"
confirmText="确认停用"
cancelText="取消"
title={t('medications.manage.deactivate.title', { name: medicationToDeactivate.name })}
description={t('medications.manage.deactivate.description')}
confirmText={t('medications.manage.deactivate.confirm')}
cancelText={t('medications.manage.deactivate.cancel')}
destructive
loading={deactivateLoading}
/>
@@ -419,7 +425,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
title: {
fontSize: 26,
fontSize: 24,
fontWeight: '600',
},
subtitle: {

View File

@@ -1,5 +1,6 @@
import { ThemedText } from '@/components/ThemedText';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useI18n } from '@/hooks/useI18n';
import { useNotifications } from '@/hooks/useNotifications';
import {
getMedicationReminderEnabled,
@@ -18,6 +19,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function NotificationSettingsScreen() {
const insets = useSafeAreaInsets();
const { pushIfAuthedElseLogin } = useAuthGuard();
const { t } = useI18n();
const { requestPermission, sendNotification } = useNotifications();
const isLgAvailable = isLiquidGlassAvailable();
@@ -36,7 +38,7 @@ export default function NotificationSettingsScreen() {
setNotificationEnabledState(notification);
setMedicationReminderEnabledState(medicationReminder);
} catch (error) {
console.error('加载通知设置失败:', error);
console.error('Failed to load notification settings:', error);
} finally {
setIsLoading(false);
}
@@ -62,25 +64,25 @@ export default function NotificationSettingsScreen() {
// 发送测试通知
await sendNotification({
title: '通知已开启',
body: '您将收到应用通知和提醒',
title: t('notificationSettings.alerts.notificationsEnabled.title'),
body: t('notificationSettings.alerts.notificationsEnabled.body'),
sound: true,
priority: 'normal',
});
} else {
// 系统权限被拒绝,不更新用户偏好设置
Alert.alert(
'权限被拒绝',
'请在系统设置中开启通知权限,然后再尝试开启推送功能',
t('notificationSettings.alerts.permissionDenied.title'),
t('notificationSettings.alerts.permissionDenied.message'),
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() }
{ text: t('notificationSettings.alerts.permissionDenied.cancel'), style: 'cancel' },
{ text: t('notificationSettings.alerts.permissionDenied.goToSettings'), onPress: () => Linking.openSettings() }
]
);
}
} catch (error) {
console.error('开启推送通知失败:', error);
Alert.alert('错误', '请求通知权限失败');
console.error('Failed to enable push notifications:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.message'));
}
} else {
try {
@@ -91,8 +93,8 @@ export default function NotificationSettingsScreen() {
await setMedicationReminderEnabled(false);
setMedicationReminderEnabledState(false);
} catch (error) {
console.error('关闭推送通知失败:', error);
Alert.alert('错误', '保存设置失败');
console.error('Failed to disable push notifications:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
}
}
};
@@ -106,15 +108,15 @@ export default function NotificationSettingsScreen() {
if (value) {
// 发送测试通知
await sendNotification({
title: '药品提醒已开启',
body: '您将在用药时间收到提醒通知',
title: t('notificationSettings.alerts.medicationReminderEnabled.title'),
body: t('notificationSettings.alerts.medicationReminderEnabled.body'),
sound: true,
priority: 'high',
});
}
} catch (error) {
console.error('设置药品提醒失败:', error);
Alert.alert('错误', '保存设置失败');
console.error('Failed to set medication reminder:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.medicationReminderFailed'));
}
};
@@ -183,7 +185,7 @@ export default function NotificationSettingsScreen() {
end={{ x: 0, y: 1 }}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
</View>
</View>
);
@@ -217,16 +219,16 @@ export default function NotificationSettingsScreen() {
{/* 头部 */}
<View style={styles.header}>
<BackButton />
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.title}>{t('notificationSettings.title')}</ThemedText>
</View>
{/* 通知设置部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.notifications')}</Text>
<View style={styles.card}>
<SwitchItem
title="消息推送"
description="开启后将接收应用通知"
title={t('notificationSettings.items.pushNotifications.title')}
description={t('notificationSettings.items.pushNotifications.description')}
value={notificationEnabled}
onValueChange={handleNotificationToggle}
/>
@@ -235,11 +237,11 @@ export default function NotificationSettingsScreen() {
{/* 药品提醒部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.medicationReminder')}</Text>
<View style={styles.card}>
<SwitchItem
title="药品通知提醒"
description="在用药时间接收提醒通知"
title={t('notificationSettings.items.medicationReminder.title')}
description={t('notificationSettings.items.medicationReminder.description')}
value={medicationReminderEnabled}
onValueChange={handleMedicationReminderToggle}
disabled={!notificationEnabled}
@@ -249,13 +251,10 @@ export default function NotificationSettingsScreen() {
{/* 说明部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.card}>
<Text style={styles.description}>
{'\n'}
使{'\n'}
{'\n'}
{t('notificationSettings.description.text')}
</Text>
</View>
</View>

View File

@@ -3,6 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
import { fetchMaximumHeartRate } from '@/utils/health';
@@ -46,6 +47,7 @@ interface UserProfile {
const STORAGE_KEY = '@user_profile';
export default function EditProfileScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
@@ -189,7 +191,7 @@ export default function EditProfileScreen() {
const handleSaveWithProfile = async (profileData: UserProfile) => {
try {
if (!userId) {
Alert.alert('未登录', '请先登录后再尝试保存');
Alert.alert(t('editProfile.alerts.notLoggedIn.title'), t('editProfile.alerts.notLoggedIn.message'));
return;
}
const next: UserProfile = { ...profileData };
@@ -215,7 +217,7 @@ export default function EditProfileScreen() {
console.warn('更新用户信息失败', e?.message || e);
}
} catch (e) {
Alert.alert('保存失败', '请稍后重试');
Alert.alert(t('editProfile.alerts.saveFailed.title'), t('editProfile.alerts.saveFailed.message'));
}
};
@@ -249,7 +251,7 @@ export default function EditProfileScreen() {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择头像');
Alert.alert(t('editProfile.alerts.avatarPermissions.title'), t('editProfile.alerts.avatarPermissions.message'));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -275,21 +277,21 @@ export default function EditProfileScreen() {
setProfile((p) => ({ ...p, avatarUri: url }));
// 保存更新后的 profile
await handleSaveWithProfile({ ...profile, avatarUri: url });
Alert.alert('成功', '头像更新成功');
Alert.alert(t('editProfile.alerts.avatarSuccess.title'), t('editProfile.alerts.avatarSuccess.message'));
} catch (e) {
console.warn('上传头像失败', e);
Alert.alert('上传失败', '头像上传失败,请重试');
Alert.alert(t('editProfile.alerts.avatarUploadFailed.title'), t('editProfile.alerts.avatarUploadFailed.message'));
}
}
} catch (e) {
Alert.alert('发生错误', '选择头像失败,请重试');
Alert.alert(t('editProfile.alerts.avatarError.title'), t('editProfile.alerts.avatarError.message'));
}
};
return (
<View style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
<HeaderBar
title="编辑资料"
title={t('editProfile.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -321,8 +323,8 @@ export default function EditProfileScreen() {
{/* 姓名 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
title="昵称"
value={profile.name || '今晚要吃肉'}
title={t('editProfile.fields.name')}
value={profile.name || t('editProfile.defaultValues.name')}
onPress={() => {
setTempValue(profile.name || '');
setEditingField('name');
@@ -334,8 +336,8 @@ export default function EditProfileScreen() {
icon="body"
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-sex.png"
iconColor="#FF6B9D"
title="性别"
value={profile.gender === 'male' ? '男' : profile.gender === 'female' ? '女' : '未设置'}
title={t('editProfile.fields.gender')}
value={profile.gender === 'male' ? t('editProfile.gender.male') : profile.gender === 'female' ? t('editProfile.gender.female') : t('editProfile.gender.notSet')}
onPress={() => {
setEditingField('gender');
}}
@@ -344,10 +346,10 @@ export default function EditProfileScreen() {
{/* 身高 */}
<ProfileCard
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
title="身高"
value={profile.height ? `${Math.round(profile.height)}厘米` : '170厘米'}
title={t('editProfile.fields.height')}
value={profile.height ? `${Math.round(profile.height)}${t('editProfile.height.unit')}` : t('editProfile.height.placeholder')}
onPress={() => {
setTempValue(profile.height ? String(Math.round(profile.height)) : '170');
setTempValue(profile.height ? String(Math.round(profile.height)) : String(t('editProfile.defaultValues.height')));
setEditingField('height');
}}
/>
@@ -355,10 +357,10 @@ export default function EditProfileScreen() {
{/* 体重 */}
<ProfileCard
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
title="体重"
value={profile.weight ? `${round(profile.weight, 1)}公斤` : '55公斤'}
title={t('editProfile.fields.weight')}
value={profile.weight ? `${round(profile.weight, 1)}${t('editProfile.weight.unit')}` : t('editProfile.weight.placeholder')}
onPress={() => {
setTempValue(profile.weight ? String(round(profile.weight, 1)) : '55');
setTempValue(profile.weight ? String(round(profile.weight, 1)) : String(t('editProfile.defaultValues.weight')));
setEditingField('weight');
}}
/>
@@ -366,14 +368,14 @@ export default function EditProfileScreen() {
{/* 活动水平 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
title="活动水平"
title={t('editProfile.fields.activityLevel')}
value={(() => {
switch (profile.activityLevel) {
case 1: return '久坐';
case 2: return '轻度活跃';
case 3: return '中度活跃';
case 4: return '非常活跃';
default: return '久坐';
case 1: return t('editProfile.activityLevels.1');
case 2: return t('editProfile.activityLevels.2');
case 3: return t('editProfile.activityLevels.3');
case 4: return t('editProfile.activityLevels.4');
default: return t('editProfile.activityLevels.1');
}
})()}
onPress={() => {
@@ -384,15 +386,20 @@ export default function EditProfileScreen() {
{/* 出生日期 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
title="出生日期"
title={t('editProfile.fields.birthDate')}
value={profile.birthDate ? (() => {
try {
const d = new Date(profile.birthDate);
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
if (t('editProfile.birthDate.format').includes('{{year}}年')) {
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() });
} else {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: monthNames[d.getMonth()], day: d.getDate() });
}
} catch {
return '1995年1月1日';
return t('editProfile.birthDate.placeholder');
}
})() : '1995年1月1日'}
})() : t('editProfile.birthDate.placeholder')}
onPress={() => {
openDatePicker();
}}
@@ -402,11 +409,11 @@ export default function EditProfileScreen() {
<ProfileCard
icon="heart"
iconColor="#FF6B9D"
title="最大心率"
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
title={t('editProfile.fields.maxHeartRate')}
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}${t('editProfile.maxHeartRate.unit')}` : t('editProfile.maxHeartRate.notAvailable')}
onPress={() => {
// 最大心率不可编辑,只显示
Alert.alert('提示', '最大心率数据从健康应用自动获取');
Alert.alert(t('editProfile.maxHeartRate.alert.title'), t('editProfile.maxHeartRate.alert.message'));
}}
disabled={true}
hideArrow={true}
@@ -432,6 +439,7 @@ export default function EditProfileScreen() {
} else if (field === 'gender') {
updatedProfile.gender = value as 'male' | 'female';
setProfile(p => ({ ...p, gender: value as 'male' | 'female' }));
} else if (field === 'height') {
updatedProfile.height = parseFloat(value) || undefined;
setProfile(p => ({ ...p, height: parseFloat(value) || undefined }));
@@ -455,8 +463,8 @@ export default function EditProfileScreen() {
colors={colors}
textColor={textColor}
placeholderColor={placeholderColor}
t={t}
/>
{/* 出生日期选择器弹窗 */}
<Modal
visible={datePickerVisible}
@@ -488,12 +496,12 @@ export default function EditProfileScreen() {
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('editProfile.modals.cancel')}</Text>
</Pressable>
<Pressable onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('editProfile.modals.confirm')}</Text>
</Pressable>
</View>
)}
@@ -536,7 +544,7 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
);
}
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor }: {
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor, t }: {
visible: boolean;
field: string | null;
value: string;
@@ -546,6 +554,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
colors: any;
textColor: string;
placeholderColor: string;
t: (key: string) => string;
}) {
const [inputValue, setInputValue] = useState(value);
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
@@ -563,10 +572,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'name':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.name')}</Text>
<TextInput
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
placeholder="输入昵称"
placeholder={t('editProfile.modals.input.namePlaceholder')}
placeholderTextColor={placeholderColor}
value={inputValue}
onChangeText={setInputValue}
@@ -577,21 +586,21 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'gender':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.gender')}</Text>
<View style={styles.genderSelector}>
<TouchableOpacity
style={[styles.genderOption, selectedGender === 'female' && { backgroundColor: colors.primary + '20' }]}
onPress={() => setSelectedGender('female')}
>
<Text style={[styles.genderEmoji, selectedGender === 'female' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>{t('editProfile.modals.female')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.genderOption, selectedGender === 'male' && { backgroundColor: colors.primary + '20' }]}
onPress={() => setSelectedGender('male')}
>
<Text style={[styles.genderEmoji, selectedGender === 'male' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>{t('editProfile.modals.male')}</Text>
</TouchableOpacity>
</View>
</View>
@@ -599,7 +608,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'height':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.height')}</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={inputValue}
@@ -607,7 +616,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
style={styles.picker}
>
{Array.from({ length: 101 }, (_, i) => 120 + i).map(height => (
<Picker.Item key={height} label={`${height}厘米`} value={String(height)} />
<Picker.Item key={height} label={`${height}${t('editProfile.height.unit')}`} value={String(height)} />
))}
</Picker>
</View>
@@ -616,29 +625,29 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'weight':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.weight')}</Text>
<TextInput
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
placeholder="输入体重"
placeholder={t('editProfile.modals.input.weightPlaceholder')}
placeholderTextColor={placeholderColor}
value={inputValue}
onChangeText={setInputValue}
keyboardType="numeric"
autoFocus
/>
<Text style={styles.unitText}> (kg)</Text>
<Text style={styles.unitText}>{t('editProfile.modals.input.weightUnit')}</Text>
</View>
);
case 'activity':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.activityLevel')}</Text>
<View style={styles.activitySelector}>
{[
{ key: 1, label: '久坐', desc: '很少运动' },
{ key: 2, label: '轻度活跃', desc: '每周1-3次运动' },
{ key: 3, label: '中度活跃', desc: '每周3-5次运动' },
{ key: 4, label: '非常活跃', desc: '每周6-7次运动' },
{ key: 1, label: t('editProfile.activityLevels.1'), desc: t('editProfile.activityLevels.descriptions.1') },
{ key: 2, label: t('editProfile.activityLevels.2'), desc: t('editProfile.activityLevels.descriptions.2') },
{ key: 3, label: t('editProfile.activityLevels.3'), desc: t('editProfile.activityLevels.descriptions.3') },
{ key: 4, label: t('editProfile.activityLevels.4'), desc: t('editProfile.activityLevels.descriptions.4') },
].map(item => (
<TouchableOpacity
key={item.key}
@@ -668,7 +677,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
{renderContent()}
<View style={styles.modalButtons}>
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
<Text style={styles.modalCancelText}></Text>
<Text style={styles.modalCancelText}>{t('editProfile.modals.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
@@ -682,7 +691,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
}}
style={[styles.modalSaveBtn, { backgroundColor: colors.primary }]}
>
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}></Text>
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>{t('editProfile.modals.save')}</Text>
</TouchableOpacity>
</View>
</View>