feat(i18n): 全面实现应用核心功能模块的国际化支持

- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
This commit is contained in:
richarjiang
2025-11-27 17:54:36 +08:00
parent 08adf0f20d
commit fbe0c92f0f
26 changed files with 2508 additions and 1622 deletions

View File

@@ -1,4 +1,5 @@
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { useI18n } from '@/hooks/useI18n';
import { getMonthDays, getMonthTitle, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
autoScrollToSelected = true,
showCalendarIcon = true,
}) => {
const { t, i18n } = useI18n();
// 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const isGlassAvailable = isLiquidGlassAvailable();
// 获取日期数据
const days = getMonthDaysZh(currentMonth);
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
// 判断当前选中的日期是否是今天
const isSelectedDateToday = () => {
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
setCurrentMonth(selectedMonth);
// 计算选中日期在新月份中的索引
const newMonthDays = getMonthDaysZh(selectedMonth);
const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
const selectedDay = selectedMonth.date();
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const handleGoToday = () => {
const today = dayjs();
setCurrentMonth(today);
const todayDays = getMonthDaysZh(today);
const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
if (newSelectedIndex !== -1) {
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true}
>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</GlassView>
) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</View>
)}
</TouchableOpacity>
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
};
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} 小时`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
console.log('unit', unit);
const { t } = useI18n();
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} ${t('challengeDetail.ranking.hour')}`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
: reportedLabel
? `今日 ${reportedLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
: targetLabel
? `今日目标 ${targetLabel}`
? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
: undefined;
return (

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
@@ -25,6 +26,8 @@ interface MedicationPhotoGuideModalProps {
* 展示如何正确拍摄药品照片的说明和示例
*/
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
const { t } = useI18n();
return (
<Modal
visible={visible}
@@ -48,8 +51,12 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
>
{/* 标题部分 */}
<View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text>
<Text style={styles.guideTitle}></Text>
<Text style={styles.guideStepBadge}>
{t('medications.aiCamera.guideModal.badge')}
</Text>
<Text style={styles.guideTitle}>
{t('medications.aiCamera.guideModal.title')}
</Text>
</View>
{/* 示例图片 */}
@@ -99,10 +106,10 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
{/* 说明文字 */}
<View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}>
\\
{t('medications.aiCamera.guideModal.description1')}
</Text>
<Text style={styles.guideDescriptionText}>
线
{t('medications.aiCamera.guideModal.description2')}
</Text>
</View>
@@ -124,7 +131,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</GlassView>
) : (
@@ -135,7 +144,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}></Text>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</View>
)}

View File

@@ -11,6 +11,7 @@ import {
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// 睡眠详情数据类型
export type SleepDetailData = {
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
range: string;
isActive?: boolean;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const getGradeColor = (grade: string) => {
switch (grade) {
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' };
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' };
case '良好': return { bg: '#D1FAE5', text: '#065F46' };
case '优秀': return { bg: '#FEF3C7', text: '#92400E' };
case t('sleepDetail.sleepGrades.low'):
case t('sleepDetail.sleepGrades.poor'):
return { bg: '#FECACA', text: '#DC2626' };
case t('sleepDetail.sleepGrades.normal'):
case t('sleepDetail.sleepGrades.fair'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.good'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.excellent'):
return { bg: '#FEF3C7', text: '#92400E' };
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
}
};
@@ -97,6 +105,7 @@ export const InfoModal = ({
type: 'sleep-time' | 'sleep-quality';
sleepData: SleepDetailData;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
@@ -153,26 +162,26 @@ export const InfoModal = ({
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
const sleepTimeGrades = [
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
];
const sleepQualityGrades = [
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
];
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
const getDescription = () => {
if (type === 'sleep-time') {
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。';
return t('sleepDetail.sleepTimeDescription');
} else {
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长还包括睡眠的连续性和各睡眠阶段的平衡。';
return t('sleepDetail.sleepQualityDescription');
}
};

View File

@@ -7,18 +7,24 @@ import React, { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
export type SleepStageTimelineProps = {
sleepSamples: SleepSample[];
bedtime: string;
wakeupTime: string;
onInfoPress?: () => void;
hideHeader?: boolean;
style?: StyleProp<ViewStyle>;
};
export const SleepStageTimeline = ({
sleepSamples,
bedtime,
wakeupTime,
onInfoPress
onInfoPress,
hideHeader = false,
style
}: SleepStageTimelineProps) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -130,15 +136,17 @@ export const SleepStageTimeline = ({
// 如果没有数据,显示空状态
if (timelineData.length === 0) {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
@@ -149,16 +157,18 @@ export const SleepStageTimeline = ({
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{/* 标题栏 */}
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
{!hideHeader && (
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
)}
{/* 睡眠时间范围 */}
<View style={styles.timeRange}>

View File

@@ -13,6 +13,7 @@ import {
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// Sleep Stages Info Modal 组件
export const SleepStagesInfoModal = ({
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
visible: boolean;
onClose: () => void;
}) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
<View style={styles.sleepStagesModalHeader}>
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStagesInfo.title')}
</Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={24} color={colorTokens.textSecondary} />
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
scrollEnabled={true}
>
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.description')}
</Text>
{/* 清醒时间 */}
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.awake.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.awake.description')}
</Text>
</View>
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.rem.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.rem.description')}
</Text>
</View>
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.core.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.core.description')}
</Text>
</View>
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.deep.title')}</Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.deep.description')}
</Text>
</View>
</ScrollView>

View File

@@ -1,5 +1,6 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
@@ -20,6 +21,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
onDelete,
weightChange = 0
}) => {
const { t } = useI18n();
const swipeableRef = useRef<Swipeable>(null);
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
@@ -27,15 +29,15 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
`确定要删除这条体重记录吗?此操作无法撤销。`,
t('weightRecords.card.deleteConfirmTitle'),
t('weightRecords.card.deleteConfirmMessage'),
[
{
text: '取消',
text: t('weightRecords.card.cancelButton'),
style: 'cancel',
},
{
text: '删除',
text: t('weightRecords.card.deleteButton'),
style: 'destructive',
onPress: () => {
const recordId = record.id || record.createdAt;
@@ -56,7 +58,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
<Text style={styles.deleteButtonText}>{t('weightRecords.card.deleteButton')}</Text>
</TouchableOpacity>
);
};
@@ -73,7 +75,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
>
<View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
{dayjs(record.createdAt).format('MM[月]DD[日] HH:mm')}
</Text>
<TouchableOpacity
style={styles.recordEditButton}
@@ -83,8 +85,8 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
</TouchableOpacity>
</View>
<View style={styles.recordContent}>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}></Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}kg</Text>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.card.weightLabel')}</Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}{t('weightRecords.modal.unit')}</Text>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.weightChangeTag,

View File

@@ -15,6 +15,7 @@ import {
View,
} from 'react-native';
import { useI18n } from '@/hooks/useI18n';
import {
HeartRateZoneStat,
WorkoutDetailMetrics,
@@ -59,6 +60,7 @@ export function WorkoutDetailModal({
onRetry,
errorMessage,
}: WorkoutDetailModalProps) {
const { t } = useI18n();
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
const [isMounted, setIsMounted] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
@@ -229,26 +231,26 @@ export function WorkoutDetailModal({
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>...</Text>
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<TouchableOpacity
onPress={() => setShowIntensityInfo(true)}
style={styles.metricInfoButton}
@@ -262,9 +264,9 @@ export function WorkoutDetailModal({
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -275,11 +277,11 @@ export function WorkoutDetailModal({
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || '未能获取到完整的锻炼详情'}
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</TouchableOpacity>
) : null}
</View>
@@ -288,7 +290,7 @@ export function WorkoutDetailModal({
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
@@ -299,21 +301,21 @@ export function WorkoutDetailModal({
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -336,7 +338,7 @@ export function WorkoutDetailModal({
width={Dimensions.get('window').width - 72}
height={220}
fromZero={false}
yAxisSuffix="次/分"
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
chartConfig={{
@@ -360,20 +362,20 @@ export function WorkoutDetailModal({
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>线</Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}></Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || '未获取到心率数据'}
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
@@ -381,7 +383,7 @@ export function WorkoutDetailModal({
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
@@ -391,7 +393,7 @@ export function WorkoutDetailModal({
) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone)
) : (
<Text style={styles.errorTextSmall}></Text>
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
@@ -410,36 +412,36 @@ export function WorkoutDetailModal({
<TouchableWithoutFeedback onPress={() => { }}>
<View style={styles.intensityInfoSheet}>
<View style={styles.intensityHandle} />
<Text style={styles.intensityInfoTitle}></Text>
<Text style={styles.intensityInfoTitle}>{t('workoutDetail.intensityInfo.title')}</Text>
<Text style={styles.intensityInfoText}>
MET/·
{t('workoutDetail.intensityInfo.description1')}
</Text>
<Text style={styles.intensityInfoText}>
MET 便
{t('workoutDetail.intensityInfo.description2')}
</Text>
<Text style={styles.intensityInfoText}>
3 km/h 2 METs 2
{t('workoutDetail.intensityInfo.description3')}
</Text>
<Text style={styles.intensityInfoText}>
METs 使70
{t('workoutDetail.intensityInfo.description4')}
</Text>
<View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}></Text>
<Text style={styles.intensityFormulaValue}>METs = / ÷ 1 /</Text>
<Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
<Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
</View>
<View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>3 - 6</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</Text>
</View>
</View>
</View>