feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块 - 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本 - 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示 - 完善登录页、注销流程及权限申请弹窗的双语提示信息 - 优化部分页面的 UI 细节与字体样式以适配多语言显示
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user