feat(i18n): 实现应用国际化支持,添加中英文翻译
- 为所有UI组件添加国际化支持,替换硬编码文本 - 新增useI18n钩子函数统一管理翻译 - 完善中英文翻译资源,覆盖统计、用药、通知设置等模块 - 优化Tab布局使用翻译键值替代静态文本 - 更新药品管理、个人资料编辑等页面的多语言支持
This commit is contained in:
@@ -6,6 +6,7 @@ import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface BasalMetabolismCardProps {
|
||||
@@ -14,6 +15,7 @@ interface BasalMetabolismCardProps {
|
||||
}
|
||||
|
||||
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -90,7 +92,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||||
console.error('BasalMetabolismCard: Failed to get basal metabolism data:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理请求记录
|
||||
@@ -134,20 +136,20 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
// 使用 useMemo 优化状态描述计算
|
||||
const status = useMemo(() => {
|
||||
if (basalMetabolism === null || basalMetabolism === 0) {
|
||||
return { text: '未知', color: '#9AA3AE' };
|
||||
return { text: t('statistics.components.metabolism.status.unknown'), color: '#9AA3AE' };
|
||||
}
|
||||
|
||||
// 基于常见的基础代谢范围来判断状态
|
||||
if (basalMetabolism >= 1800) {
|
||||
return { text: '高代谢', color: '#10B981' };
|
||||
return { text: t('statistics.components.metabolism.status.high'), color: '#10B981' };
|
||||
} else if (basalMetabolism >= 1400) {
|
||||
return { text: '正常', color: '#3B82F6' };
|
||||
return { text: t('statistics.components.metabolism.status.normal'), color: '#3B82F6' };
|
||||
} else if (basalMetabolism >= 1000) {
|
||||
return { text: '偏低', color: '#F59E0B' };
|
||||
return { text: t('statistics.components.metabolism.status.low'), color: '#F59E0B' };
|
||||
} else {
|
||||
return { text: '较低', color: '#EF4444' };
|
||||
return { text: t('statistics.components.metabolism.status.veryLow'), color: '#EF4444' };
|
||||
}
|
||||
}, [basalMetabolism]);
|
||||
}, [basalMetabolism, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -163,7 +165,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
source={require('@/assets/images/icons/icon-fire.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>基础代谢</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.metabolism.title')}</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
||||
@@ -173,9 +175,9 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
<Text style={styles.value}>
|
||||
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
{loading ? t('statistics.components.metabolism.loading') : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
</Text>
|
||||
<Text style={styles.unit}>千卡/日</Text>
|
||||
<Text style={styles.unit}>{t('statistics.components.metabolism.unit')}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MoodCardProps {
|
||||
@@ -11,6 +12,7 @@ interface MoodCardProps {
|
||||
}
|
||||
|
||||
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null;
|
||||
const animationRef = useRef<LottieView>(null);
|
||||
|
||||
@@ -28,7 +30,7 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
source={require('@/assets/images/icons/icon-mood.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.mood.title')}</Text>
|
||||
</View>
|
||||
<LottieView
|
||||
ref={animationRef}
|
||||
@@ -48,7 +50,7 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.moodEmptyText}>点击记录心情</Text>
|
||||
<Text style={styles.moodEmptyText}>{t('statistics.components.mood.empty')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { calculateRemainingCalories } from '@/utils/nutrition';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
|
||||
@@ -25,10 +26,12 @@ export type NutritionRadarCardProps = {
|
||||
// 简化的圆环进度组件
|
||||
const SimpleRingProgress = ({
|
||||
remainingCalories,
|
||||
totalAvailable
|
||||
totalAvailable,
|
||||
t
|
||||
}: {
|
||||
remainingCalories: number;
|
||||
totalAvailable: number;
|
||||
t: any;
|
||||
}) => {
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const radius = 32;
|
||||
@@ -82,7 +85,7 @@ const SimpleRingProgress = ({
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
|
||||
{Math.round(remainingCalories)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>还能吃</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>{t('statistics.components.diet.remaining')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -93,6 +96,7 @@ export function NutritionRadarCard({
|
||||
style,
|
||||
resetToken,
|
||||
}: NutritionRadarCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -122,7 +126,7 @@ export function NutritionRadarCard({
|
||||
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
|
||||
console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -133,14 +137,14 @@ export function NutritionRadarCard({
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
||||
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||
{ label: '纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||
{ label: t('statistics.components.diet.calories'), value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} ${t('statistics.components.diet.kcal')}` : `0 ${t('statistics.components.diet.kcal')}`, color: '#FF6B6B' },
|
||||
{ label: t('statistics.components.diet.protein'), value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||
{ label: t('statistics.components.diet.carb'), value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||
{ label: t('statistics.components.diet.fat'), value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||
{ label: t('statistics.components.diet.fiber'), value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||
{ label: t('statistics.components.diet.sodium'), value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||
];
|
||||
}, [nutritionSummary]);
|
||||
}, [nutritionSummary, t]);
|
||||
|
||||
// 计算还能吃的卡路里
|
||||
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
||||
@@ -168,10 +172,10 @@ export function NutritionRadarCard({
|
||||
source={require('@/assets/images/icons/icon-healthy-diet.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.diet.title')}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
|
||||
{loading ? t('statistics.components.diet.loading') : t('statistics.components.diet.updated', { time: dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm') })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -180,6 +184,7 @@ export function NutritionRadarCard({
|
||||
<SimpleRingProgress
|
||||
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -199,7 +204,7 @@ export function NutritionRadarCard({
|
||||
<View style={styles.calorieSection}>
|
||||
<View style={styles.calorieContent}>
|
||||
<View style={styles.calculationRow}>
|
||||
<Text style={styles.calorieSubtitle}>还能吃</Text>
|
||||
<Text style={styles.calorieSubtitle}>{t('statistics.components.diet.remaining')}</Text>
|
||||
<View style={styles.remainingCaloriesContainer}>
|
||||
<AnimatedNumber
|
||||
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
@@ -207,11 +212,11 @@ export function NutritionRadarCard({
|
||||
style={styles.mainValue}
|
||||
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
<Text style={styles.calorieUnit}>千卡</Text>
|
||||
<Text style={styles.calorieUnit}>{t('statistics.components.diet.kcal')}</Text>
|
||||
</View>
|
||||
<Text style={styles.calculationText}> = </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>基代</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.basal')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={loading ? 0 : effectiveBasalMetabolism}
|
||||
@@ -221,7 +226,7 @@ export function NutritionRadarCard({
|
||||
/>
|
||||
<Text style={styles.calculationText}> + </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>运动</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.exercise')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
|
||||
@@ -231,7 +236,7 @@ export function NutritionRadarCard({
|
||||
/>
|
||||
<Text style={styles.calculationText}> - </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>饮食</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.diet')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={loading ? 0 : consumedCalories}
|
||||
@@ -260,7 +265,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>AI识别</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.aiRecognition')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -277,7 +282,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>食物库</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.foodLibrary')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -294,7 +299,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>一句话记录</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.voiceRecord')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -311,7 +316,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>成分表分析</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.nutritionLabel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
// 使用原生View来替代SVG,避免导入问题
|
||||
// import Svg, { Rect } from 'react-native-svg';
|
||||
@@ -28,6 +29,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
curDate,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [stepCount, setStepCount] = useState(0)
|
||||
@@ -36,7 +38,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
|
||||
const getStepData = useCallback(async (date: Date) => {
|
||||
try {
|
||||
logger.info('获取步数数据...');
|
||||
logger.info('Getting step data...');
|
||||
|
||||
// 先获取步数,立即更新UI
|
||||
const [steps, hourly] = await Promise.all([
|
||||
@@ -47,7 +49,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
setHourSteps(hourly);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取步数数据失败:', error);
|
||||
logger.error('Failed to get step data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -122,7 +124,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
source={require('@/assets/images/icons/icon-step.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>步数</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.steps.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 柱状图 */}
|
||||
@@ -190,7 +192,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
<AnimatedNumber
|
||||
value={stepCount || 0}
|
||||
style={styles.stepCount}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '--'}
|
||||
resetToken={stepCount}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fetchHRVWithStatus } from '@/utils/health';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { StressAnalysisModal } from './StressAnalysisModal';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface StressMeterProps {
|
||||
}
|
||||
|
||||
export function StressMeter({ curDate }: StressMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 将HRV值转换为压力指数(0-100)
|
||||
// HRV值范围:30-110ms,映射到压力指数100-0
|
||||
@@ -34,23 +36,23 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
const getHrvData = async () => {
|
||||
try {
|
||||
console.log('StressMeter: 开始获取HRV数据...', curDate);
|
||||
console.log('StressMeter: Starting to get HRV data...', curDate);
|
||||
|
||||
// 使用智能HRV数据获取功能
|
||||
const result = await fetchHRVWithStatus(curDate);
|
||||
|
||||
console.log('StressMeter: HRV数据获取结果:', result);
|
||||
console.log('StressMeter: HRV data fetch result:', result);
|
||||
|
||||
if (result.hrvData) {
|
||||
setHrvValue(Math.round(result.hrvData.value));
|
||||
console.log(`StressMeter: 使用${result.message},HRV值: ${result.hrvData.value}ms`);
|
||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||
} else {
|
||||
console.log('StressMeter: 未获取到HRV数据');
|
||||
console.log('StressMeter: No HRV data obtained');
|
||||
// 可以设置一个默认值或者显示无数据状态
|
||||
setHrvValue(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('StressMeter: 获取HRV数据失败:', error);
|
||||
console.error('StressMeter: Failed to get HRV data:', error);
|
||||
setHrvValue(0);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +86,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
source={require('@/assets/images/icons/icon-pressure.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>压力</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.stress.title')}</Text>
|
||||
</View>
|
||||
{/* {updateTime && (
|
||||
<Text style={styles.headerUpdateTime}>{formatUpdateTime(updateTime)}</Text>
|
||||
@@ -94,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
<Text style={styles.value}>{hrvValue || '--'}</Text>
|
||||
<Text>ms</Text>
|
||||
<Text>{t('statistics.components.stress.unit')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 进度条区域 */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Animated,
|
||||
StyleSheet,
|
||||
@@ -26,6 +27,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
style,
|
||||
selectedDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate);
|
||||
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
|
||||
@@ -89,7 +91,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
|
||||
await getWaterRecordsByDate(targetDate);
|
||||
} catch (error) {
|
||||
console.error('页面聚焦时加载数据失败:', error);
|
||||
console.error('Failed to load data on focus:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,11 +187,11 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>喝水</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.water.title')}</Text>
|
||||
</View>
|
||||
{isToday && (
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleQuickAddWater}>
|
||||
<Text style={styles.addButtonText}>+ {quickWaterAmount}ml</Text>
|
||||
<Text style={styles.addButtonText}>{t('statistics.components.water.addButton', { amount: quickWaterAmount })}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -254,14 +256,14 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
<AnimatedNumber
|
||||
value={currentIntake}
|
||||
style={styles.currentIntake}
|
||||
format={(value) => `${Math.round(value)}ml`}
|
||||
format={(value) => `${Math.round(value)}${t('statistics.components.water.unit')}`}
|
||||
resetToken={selectedDate}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.currentIntake}>——</Text>
|
||||
<Text style={styles.currentIntake}>--</Text>
|
||||
)}
|
||||
<Text style={styles.targetIntake}>
|
||||
/ {targetIntake}ml
|
||||
/ {targetIntake}{t('statistics.components.water.unit')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
@@ -40,6 +41,7 @@ const DEFAULT_SUMMARY: WorkoutSummary = {
|
||||
|
||||
|
||||
export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, style }) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [summary, setSummary] = useState<WorkoutSummary>(DEFAULT_SUMMARY);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -145,13 +147,13 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
||||
|
||||
const label = lastWorkout
|
||||
? getWorkoutTypeDisplayName(lastWorkout.workoutActivityType)
|
||||
: '尚无锻炼数据';
|
||||
: t('statistics.components.workout.noData');
|
||||
|
||||
const time = lastWorkout
|
||||
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} 更新`
|
||||
: '等待同步';
|
||||
? `${dayjs(lastWorkout.endDate || lastWorkout.startDate).format('HH:mm')} ${t('statistics.components.workout.updated')}`
|
||||
: t('statistics.components.workout.syncing');
|
||||
|
||||
let source = '来源:等待同步';
|
||||
let source = t('statistics.components.workout.sourceWaiting');
|
||||
if (hasWorkouts) {
|
||||
const sourceNames = summary.workouts
|
||||
.map((workout) => workout.source?.name?.trim() || workout.source?.bundleIdentifier?.trim())
|
||||
@@ -160,9 +162,11 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
||||
if (sourceNames.length) {
|
||||
const uniqueNames = Array.from(new Set(sourceNames));
|
||||
const displayNames = uniqueNames.slice(0, 2).join('、');
|
||||
source = uniqueNames.length > 2 ? `来源:${displayNames} 等` : `来源:${displayNames}`;
|
||||
source = uniqueNames.length > 2
|
||||
? t('statistics.components.workout.sourceFormatMultiple', { source: displayNames })
|
||||
: t('statistics.components.workout.sourceFormat', { source: displayNames });
|
||||
} else {
|
||||
source = '来源:未知';
|
||||
source = t('statistics.components.workout.sourceUnknown');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +188,7 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
||||
source,
|
||||
badges: uniqueBadges,
|
||||
};
|
||||
}, [summary]);
|
||||
}, [summary, t]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -195,18 +199,18 @@ export const WorkoutSummaryCard: React.FC<WorkoutSummaryCardProps> = ({ date, st
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.titleRow}>
|
||||
<Image source={require('@/assets/images/icons/icon-fitness.png')} style={styles.titleIcon} />
|
||||
<Text style={styles.titleText}>近期锻炼</Text>
|
||||
<Text style={styles.titleText}>{t('statistics.components.workout.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metricItem}>
|
||||
<AnimatedNumber value={summary.totalMinutes} resetToken={resetToken} style={styles.metricValue} />
|
||||
<Text style={styles.metricLabel}>分钟</Text>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.workout.minutes')}</Text>
|
||||
</View>
|
||||
<View style={styles.metricItem}>
|
||||
<AnimatedNumber value={summary.totalCalories} resetToken={resetToken} style={styles.metricValue} />
|
||||
<Text style={styles.metricLabel}>千卡</Text>
|
||||
<Text style={styles.metricLabel}>{t('statistics.components.workout.kcal')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { takeMedicationAction } from '@/store/medicationsSlice';
|
||||
import type { MedicationDisplayItem } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -19,6 +20,7 @@ export type MedicationCardProps = {
|
||||
|
||||
export function MedicationCard({ medication, colors, selectedDate, onOpenDetails, onCelebrate }: MedicationCardProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useI18n();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
@@ -43,11 +45,11 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
if (timeDiffMinutes > 60) {
|
||||
// 显示二次确认弹窗
|
||||
Alert.alert(
|
||||
'尚未到服药时间',
|
||||
`该用药计划在 ${medication.scheduledTime},现在还早于1小时以上。\n\n是否确认已服用此药物?`,
|
||||
t('medications.card.earlyTakeAlert.title'),
|
||||
t('medications.card.earlyTakeAlert.message', { time: medication.scheduledTime }),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('medications.card.earlyTakeAlert.cancel'),
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
// 用户取消,不执行任何操作
|
||||
@@ -55,7 +57,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '确认已服用',
|
||||
text: t('medications.card.earlyTakeAlert.confirm'),
|
||||
style: 'default',
|
||||
onPress: () => {
|
||||
// 用户确认,执行服药逻辑
|
||||
@@ -89,9 +91,9 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_CARD] 服药操作失败', error);
|
||||
Alert.alert(
|
||||
'操作失败',
|
||||
error instanceof Error ? error.message : '记录服药时发生错误,请稍后重试',
|
||||
[{ text: '确定' }]
|
||||
t('medications.card.takeError.title'),
|
||||
error instanceof Error ? error.message : t('medications.card.takeError.message'),
|
||||
[{ text: t('medications.card.takeError.confirm') }]
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -102,7 +104,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
if (medication.status === 'missed') {
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipMissed]}>
|
||||
<ThemedText style={styles.statusChipText}>已错过</ThemedText>
|
||||
<ThemedText style={styles.statusChipText}>{t('medications.card.status.missed')}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>到服药时间</ThemedText>
|
||||
<ThemedText style={styles.statusChipText}>{t('medications.card.status.timeToTake')}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -125,7 +127,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>剩余 {formatted}</ThemedText>
|
||||
<ThemedText style={styles.statusChipText}>{t('medications.card.status.remaining', { time: formatted })}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -138,7 +140,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
return (
|
||||
<View style={[styles.actionButton, styles.actionButtonTaken]}>
|
||||
<Ionicons name="checkmark-circle" size={18} color="#fff" />
|
||||
<ThemedText style={styles.actionButtonText}>已服用</ThemedText>
|
||||
<ThemedText style={styles.actionButtonText}>{t('medications.card.action.taken')}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -158,13 +160,13 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
isInteractive={!isSubmitting}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? '提交中...' : '立即服用'}
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? '提交中...' : '立即服用'}
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { selectUserProfile, updateUserBodyMeasurements, UserProfile } from '@/store/userSlice';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface CircumferenceCardProps {
|
||||
@@ -11,6 +12,7 @@ interface CircumferenceCardProps {
|
||||
}
|
||||
|
||||
const CircumferenceCard: React.FC<CircumferenceCardProps> = ({ style }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
|
||||
@@ -30,32 +32,32 @@ const CircumferenceCard: React.FC<CircumferenceCardProps> = ({ style }) => {
|
||||
const measurements = [
|
||||
{
|
||||
key: 'chestCircumference',
|
||||
label: '胸围',
|
||||
label: t('statistics.components.circumference.measurements.chest'),
|
||||
value: userProfile?.chestCircumference,
|
||||
},
|
||||
{
|
||||
key: 'waistCircumference',
|
||||
label: '腰围',
|
||||
label: t('statistics.components.circumference.measurements.waist'),
|
||||
value: userProfile?.waistCircumference,
|
||||
},
|
||||
{
|
||||
key: 'upperHipCircumference',
|
||||
label: '上臀围',
|
||||
label: t('statistics.components.circumference.measurements.hip'),
|
||||
value: userProfile?.upperHipCircumference,
|
||||
},
|
||||
{
|
||||
key: 'armCircumference',
|
||||
label: '臂围',
|
||||
label: t('statistics.components.circumference.measurements.arm'),
|
||||
value: userProfile?.armCircumference,
|
||||
},
|
||||
{
|
||||
key: 'thighCircumference',
|
||||
label: '大腿围',
|
||||
label: t('statistics.components.circumference.measurements.thigh'),
|
||||
value: userProfile?.thighCircumference,
|
||||
},
|
||||
{
|
||||
key: 'calfCircumference',
|
||||
label: '小腿围',
|
||||
label: t('statistics.components.circumference.measurements.calf'),
|
||||
value: userProfile?.calfCircumference,
|
||||
},
|
||||
];
|
||||
@@ -145,7 +147,7 @@ const CircumferenceCard: React.FC<CircumferenceCardProps> = ({ style }) => {
|
||||
onPress={handleCardPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.title}>围度 (cm)</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.circumference.title')}</Text>
|
||||
|
||||
<View style={styles.measurementsContainer}>
|
||||
{measurements.map((measurement, index) => (
|
||||
@@ -174,12 +176,12 @@ const CircumferenceCard: React.FC<CircumferenceCardProps> = ({ style }) => {
|
||||
setModalVisible(false);
|
||||
setSelectedMeasurement(null);
|
||||
}}
|
||||
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
|
||||
title={selectedMeasurement ? t('statistics.components.circumference.setTitle', { label: selectedMeasurement.label }) : t('statistics.components.circumference.title')}
|
||||
items={circumferenceOptions}
|
||||
selectedValue={selectedMeasurement?.currentValue}
|
||||
onValueChange={() => { }} // Real-time update not needed
|
||||
onConfirm={handleUpdateMeasurement}
|
||||
confirmButtonText="确认"
|
||||
confirmButtonText={t('statistics.components.circumference.confirm')}
|
||||
pickerHeight={180}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
import { fetchOxygenSaturation } from '@/utils/health';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
|
||||
interface OxygenSaturationCardProps {
|
||||
style?: object;
|
||||
@@ -13,6 +14,7 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
style,
|
||||
selectedDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
@@ -38,7 +40,7 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
const data = await fetchOxygenSaturation(options);
|
||||
setOxygenSaturation(data);
|
||||
} catch (error) {
|
||||
console.error('OxygenSaturationCard: 获取血氧饱和度数据失败:', error);
|
||||
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
|
||||
setOxygenSaturation(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -52,7 +54,7 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
|
||||
return (
|
||||
<HealthDataCard
|
||||
title="血氧饱和度"
|
||||
title={t('statistics.components.oxygen.title')}
|
||||
value={loading ? '--' : (oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--')}
|
||||
unit="%"
|
||||
style={style}
|
||||
|
||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
selectedDate,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeList);
|
||||
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
||||
@@ -39,7 +41,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
const data = await fetchCompleteSleepData(selectedDate);
|
||||
setSleepDuration(data?.totalSleepTime || null);
|
||||
} catch (error) {
|
||||
console.error('SleepCard: 获取睡眠数据失败:', error);
|
||||
console.error('SleepCard: Failed to get sleep data:', error);
|
||||
setSleepDuration(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -75,7 +77,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
try {
|
||||
await dispatch(reportChallengeProgress({ id: sleepChallenge.id, value: sleepDuration })).unwrap();
|
||||
} catch (error) {
|
||||
logger.warn('SleepCard: 挑战进度上报失败', { error, challengeId: sleepChallenge.id });
|
||||
logger.warn('SleepCard: Challenge progress report failed', { error, challengeId: sleepChallenge.id });
|
||||
}
|
||||
|
||||
lastReportedRef.current = { date: dateKey, value: sleepDuration };
|
||||
@@ -91,10 +93,10 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
source={require('@/assets/images/icons/icon-sleep.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.sleep.title')}</Text>
|
||||
</View>
|
||||
<Text style={styles.sleepValue}>
|
||||
{loading ? '加载中...' : (sleepDuration != null ? formatSleepTime(sleepDuration) : '--')}
|
||||
{loading ? t('statistics.components.sleep.loading') : (sleepDuration != null ? formatSleepTime(sleepDuration) : '--')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
@@ -22,13 +23,14 @@ import {
|
||||
import Svg, { Circle, Path } from 'react-native-svg';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const CARD_WIDTH = screenWidth - 40; // 减去左右边距
|
||||
const CHART_WIDTH = CARD_WIDTH - 36; // 减去卡片内边距
|
||||
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
|
||||
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
|
||||
const CHART_HEIGHT = 60;
|
||||
const PADDING = 10;
|
||||
|
||||
|
||||
export function WeightHistoryCard() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
@@ -53,7 +55,7 @@ export function WeightHistoryCard() {
|
||||
try {
|
||||
await dispatch(fetchWeightHistory() as any).unwrap();
|
||||
} catch (error) {
|
||||
console.error('加载体重历史失败:', error);
|
||||
console.error('Failed to load weight history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,20 +72,20 @@ export function WeightHistoryCard() {
|
||||
};
|
||||
|
||||
|
||||
// 处理体重历史数据
|
||||
// Process weight history data
|
||||
const sortedHistory = [...weightHistory]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.slice(-7); // 只显示最近7条记录
|
||||
.slice(-7); // Show only the last 7 records
|
||||
|
||||
// return (
|
||||
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
// <View style={styles.cardHeader}>
|
||||
// <Text style={styles.cardTitle}>体重记录</Text>
|
||||
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
// </View>
|
||||
|
||||
// <View style={styles.emptyContent}>
|
||||
// <Text style={styles.emptyDescription}>
|
||||
// 暂无体重记录,点击下方按钮开始记录
|
||||
// No weight records yet, click the button below to start recording
|
||||
// </Text>
|
||||
// <TouchableOpacity
|
||||
// style={styles.recordButton}
|
||||
@@ -94,14 +96,14 @@ export function WeightHistoryCard() {
|
||||
// activeOpacity={0.8}
|
||||
// >
|
||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
// <Text style={styles.recordButtonText}>记录体重</Text>
|
||||
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
|
||||
// </TouchableOpacity>
|
||||
// </View>
|
||||
// </TouchableOpacity>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 生成图表数据
|
||||
// Generate chart data
|
||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||
const minWeight = Math.min(...weights);
|
||||
const maxWeight = Math.max(...weights);
|
||||
@@ -110,18 +112,18 @@ export function WeightHistoryCard() {
|
||||
const points = sortedHistory.map((item, index) => {
|
||||
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
||||
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||||
// 减少顶部边距,压缩留白
|
||||
// Reduce top margin, compress whitespace
|
||||
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
|
||||
return { x, y, weight: item.weight, date: item.createdAt };
|
||||
});
|
||||
|
||||
// 生成路径
|
||||
// Generate path
|
||||
const pathData = points.map((point, index) => {
|
||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
||||
return `L ${point.x} ${point.y}`;
|
||||
}).join(' ');
|
||||
|
||||
// 如果只有一个数据点,显示为水平线
|
||||
// If there's only one data point, display as a horizontal line
|
||||
const singlePointPath = points.length === 1 ?
|
||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
||||
pathData;
|
||||
@@ -133,7 +135,7 @@ export function WeightHistoryCard() {
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||
{isLgAvaliable ? (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
@@ -160,13 +162,13 @@ export function WeightHistoryCard() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 默认显示图表 */}
|
||||
{/* Default chart display */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||
{/* 背景网格线 */}
|
||||
{/* Background grid lines */}
|
||||
|
||||
{/* 更抽象的折线 - 减小线宽和显示的细节 */}
|
||||
{/* More abstract line - reduce line width and display details */}
|
||||
<Path
|
||||
d={singlePointPath}
|
||||
stroke={Colors.light.accentGreen}
|
||||
@@ -177,7 +179,7 @@ export function WeightHistoryCard() {
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* 简化的数据点 - 更小更精致 */}
|
||||
{/* Simplified data points - smaller and more refined */}
|
||||
{points.map((point, index) => {
|
||||
const isLastPoint = index === points.length - 1;
|
||||
|
||||
@@ -197,13 +199,13 @@ export function WeightHistoryCard() {
|
||||
|
||||
</Svg>
|
||||
|
||||
{/* 精简的图表信息 */}
|
||||
{/* Concise chart information */}
|
||||
<View style={styles.chartInfo}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>{sortedHistory.length}天</Text>
|
||||
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoLabel}>
|
||||
@@ -214,7 +216,7 @@ export function WeightHistoryCard() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* BMI 信息弹窗 */}
|
||||
{/* BMI information modal */}
|
||||
<Modal
|
||||
visible={showBMIModal}
|
||||
animationType="slide"
|
||||
@@ -228,31 +230,31 @@ export function WeightHistoryCard() {
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
<ScrollView style={styles.bmiModalContent} showsVerticalScrollIndicator={false}>
|
||||
{/* 标题 */}
|
||||
<Text style={styles.bmiModalTitle}>BMI 指数说明</Text>
|
||||
{/* Title */}
|
||||
<Text style={styles.bmiModalTitle}>{t('statistics.components.weight.bmiModal.title')}</Text>
|
||||
|
||||
{/* 介绍部分 */}
|
||||
{/* Introduction section */}
|
||||
<View style={styles.bmiModalIntroSection}>
|
||||
<Text style={styles.bmiModalDescription}>
|
||||
BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标
|
||||
{t('statistics.components.weight.bmiModal.description')}
|
||||
</Text>
|
||||
<View style={styles.bmiModalFormulaContainer}>
|
||||
<Text style={styles.bmiModalFormulaText}>
|
||||
计算公式:体重(kg) ÷ 身高²(m)
|
||||
{t('statistics.components.weight.bmiModal.formula')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* BMI 分类标准 */}
|
||||
<Text style={styles.bmiModalSectionTitle}>BMI 分类标准</Text>
|
||||
{/* BMI classification standards */}
|
||||
<Text style={styles.bmiModalSectionTitle}>{t('statistics.components.weight.bmiModal.classificationTitle')}</Text>
|
||||
|
||||
<View style={styles.bmiModalStatsCard}>
|
||||
{BMI_CATEGORIES.map((category, index) => {
|
||||
const colors = [
|
||||
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 偏瘦
|
||||
{ bg: '#E8F5E8', text: Colors.light.accentGreen, border: Colors.light.accentGreen }, // 正常
|
||||
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // 超重
|
||||
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // 肥胖
|
||||
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // Underweight
|
||||
{ bg: '#E8F5E8', text: Colors.light.accentGreen, border: Colors.light.accentGreen }, // Normal
|
||||
{ bg: '#FEF3C7', text: '#B45309', border: '#F59E0B' }, // Overweight
|
||||
{ bg: '#FEE2E2', text: '#B91C1C', border: '#EF4444' } // Obese
|
||||
][index];
|
||||
|
||||
return (
|
||||
@@ -273,41 +275,41 @@ export function WeightHistoryCard() {
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 健康建议 */}
|
||||
<Text style={styles.bmiModalSectionTitle}>健康建议</Text>
|
||||
{/* Health tips */}
|
||||
<Text style={styles.bmiModalSectionTitle}>{t('statistics.components.weight.bmiModal.healthTipsTitle')}</Text>
|
||||
<View style={styles.bmiModalHealthTips}>
|
||||
<View style={styles.bmiModalTipsItem}>
|
||||
<Ionicons name="nutrition-outline" size={20} color="#3B82F6" />
|
||||
<Text style={styles.bmiModalTipsText}>保持均衡饮食,控制热量摄入</Text>
|
||||
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.nutrition')}</Text>
|
||||
</View>
|
||||
<View style={styles.bmiModalTipsItem}>
|
||||
<Ionicons name="walk-outline" size={20} color="#3B82F6" />
|
||||
<Text style={styles.bmiModalTipsText}>每周至少150分钟中等强度运动</Text>
|
||||
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.exercise')}</Text>
|
||||
</View>
|
||||
<View style={styles.bmiModalTipsItem}>
|
||||
<Ionicons name="moon-outline" size={20} color="#3B82F6" />
|
||||
<Text style={styles.bmiModalTipsText}>保证7-9小时充足睡眠</Text>
|
||||
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.sleep')}</Text>
|
||||
</View>
|
||||
<View style={styles.bmiModalTipsItem}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#3B82F6" />
|
||||
<Text style={styles.bmiModalTipsText}>定期监测体重变化,及时调整</Text>
|
||||
<Text style={styles.bmiModalTipsText}>{t('statistics.components.weight.bmiModal.tips.monitoring')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 免责声明 */}
|
||||
{/* Disclaimer */}
|
||||
<View style={styles.bmiModalDisclaimer}>
|
||||
<Ionicons name="information-circle-outline" size={16} color="#6B7280" />
|
||||
<Text style={styles.bmiModalDisclaimerText}>
|
||||
BMI 仅供参考,不能反映肌肉量、骨密度等指标。如有健康疑问,请咨询专业医生。
|
||||
{t('statistics.components.weight.bmiModal.disclaimer')}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部继续按钮 */}
|
||||
{/* Bottom continue button */}
|
||||
<View style={styles.bmiModalBottomContainer}>
|
||||
<TouchableOpacity style={styles.bmiModalContinueButton} onPress={handleHideBMIModal}>
|
||||
<View style={styles.bmiModalButtonBackground}>
|
||||
<Text style={styles.bmiModalButtonText}>继续</Text>
|
||||
<Text style={styles.bmiModalButtonText}>{t('statistics.components.weight.bmiModal.continueButton')}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.bmiModalHomeIndicator} />
|
||||
@@ -429,7 +431,7 @@ const styles = StyleSheet.create({
|
||||
color: '#192126',
|
||||
},
|
||||
|
||||
// BMI 弹窗样式
|
||||
// BMI modal styles
|
||||
bmiModalContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user