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

739 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
export default function StepsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
// 获取路由参数
const { date } = useLocalSearchParams<{ date?: string }>();
// 根据传入的日期参数计算初始选中索引
const getInitialSelectedIndex = () => {
if (date) {
const targetDate = dayjs(date);
const days = getMonthDaysZh();
const foundIndex = days.findIndex(day =>
day.date && dayjs(day.date.toDate()).isSame(targetDate, 'day')
);
return foundIndex >= 0 ? foundIndex : getTodayIndexInMonth();
}
return getTodayIndexInMonth();
};
// 日期选择相关状态
const [selectedIndex, setSelectedIndex] = useState(getInitialSelectedIndex());
// 步数数据状态
const [stepCount, setStepCount] = useState(0);
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 获取步数数据的函数,参考 StepsCard 的实现
const getStepData = async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数详情数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
]);
setStepCount(steps);
setHourSteps(hourly);
} catch (error) {
logger.error('获取步数详情数据失败:', error);
} finally {
setIsLoading(false);
}
};
// 为每个柱体创建独立的动画值
const animatedValues = useRef(
Array.from({ length: 24 }, () => new Animated.Value(0))
).current;
// 计算柱状图数据
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 找到最大步数用于计算高度比例
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 120; // 详情页面使用更大的高度
return hourlySteps.map(data => ({
...data,
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 计算平均值刻度线位置
const averageLinePosition = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0 || !chartData || chartData.length === 0) return 0;
const activeHours = hourlySteps.filter(h => h.steps > 0);
if (activeHours.length === 0) return 0;
const avgSteps = activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length;
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 120;
return maxSteps > 0 ? (avgSteps / maxSteps) * maxHeight : 0;
}, [hourlySteps, chartData]);
// 获取当前小时
const currentHour = new Date().getHours();
// 触发柱体动画
useEffect(() => {
if (chartData && chartData.length > 0) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 延迟启动动画,创建波浪效果
chartData.forEach((data, index) => {
if (data.steps > 0) {
setTimeout(() => {
Animated.spring(animatedValues[index], {
toValue: 1,
tension: 120,
friction: 8,
useNativeDriver: false,
}).start();
}, index * 50); // 每个柱体延迟50ms
}
});
}
}, [chartData, animatedValues]);
// 日期选择处理
const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index);
getStepData(date);
};
// 当路由参数变化时更新选中索引
useEffect(() => {
const newIndex = getInitialSelectedIndex();
setSelectedIndex(newIndex);
}, [date]);
// 当选中日期变化时获取数据
useEffect(() => {
if (currentSelectedDate) {
getStepData(currentSelectedDate);
}
}, [currentSelectedDate]);
// 计算总步数和平均步数
const totalSteps = stepCount || 0;
const averageHourlySteps = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) return 0;
const activeHours = hourlySteps.filter(h => h.steps > 0);
if (activeHours.length === 0) return 0;
return Math.round(activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length);
}, [hourlySteps]);
// 找出最活跃的时间段
const mostActiveHour = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) return null;
const maxStepsData = hourlySteps.reduce((max, current) =>
current.steps > max.steps ? current : max
);
return maxStepsData.steps > 0 ? maxStepsData : null;
}, [hourlySteps]);
// 活动等级配置
const activityLevels = useMemo(() => [
{ key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], [t]);
// 计算当前活动等级
const currentActivityLevel = useMemo(() => {
return activityLevels.find(level =>
totalSteps >= level.minSteps && totalSteps < level.maxSteps
) || activityLevels[0];
}, [totalSteps, activityLevels]);
// 计算下一等级
const nextActivityLevel = useMemo(() => {
const currentIndex = activityLevels.indexOf(currentActivityLevel);
return currentIndex < activityLevels.length - 1 ? activityLevels[currentIndex + 1] : null;
}, [currentActivityLevel, activityLevels]);
// 计算进度百分比
const progressPercentage = useMemo(() => {
if (!nextActivityLevel) return 100; // 已达到最高级
const rangeSize = nextActivityLevel.minSteps - currentActivityLevel.minSteps;
const currentProgress = totalSteps - currentActivityLevel.minSteps;
return Math.min(Math.max((currentProgress / rangeSize) * 100, 0), 100);
}, [totalSteps, currentActivityLevel, nextActivityLevel]);
// 倒序显示的活动等级(用于图例)
const reversedActivityLevels = useMemo(() => [...activityLevels].reverse(), [activityLevels]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<HeaderBar
title={t('stepsDetail.title')}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: safeAreaTop
}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
{/* 统计卡片 */}
<View style={styles.statsCard}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
</View>
) : (
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text>
<Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
</View>
</View>
)}
</View>
{/* 详细柱状图卡片 */}
<View style={styles.chartCard}>
<View style={styles.chartHeader}>
<Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
<Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text>
</View>
{/* 柱状图容器 */}
<View style={styles.chartContainer}>
{/* 平均值刻度线 - 放在chartArea外面相对于chartContainer定位 */}
{averageLinePosition > 0 && (
<View
style={[
styles.averageLine,
{ bottom: averageLinePosition }
]}
>
<View style={styles.averageLineDashContainer}>
{/* 创建更多的虚线段来确保完整覆盖 */}
{Array.from({ length: 80 }, (_, index) => (
<View
key={index}
style={[
styles.dashSegment,
{
marginLeft: index > 0 ? 2 : 0,
flex: 0 // 防止 flex 拉伸
}
]}
/>
))}
</View>
<Text style={styles.averageLineLabel}>
{t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
</Text>
</View>
)}
{/* 柱状图区域 */}
<View style={styles.chartArea}>
{chartData.map((data, index) => {
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
const isKeyTime = index === 0 || index === 12 || index === 23;
// 动画变换
const animatedHeight = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, data.height],
});
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
return (
<View key={`bar-${index}`} style={styles.barContainer}>
{/* 背景柱体 */}
<View
style={[
styles.backgroundBar,
{
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
}
]}
/>
{/* 数据柱体 */}
{isActive && (
<Animated.View
style={[
styles.dataBar,
{
height: animatedHeight,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
opacity: animatedOpacity,
}
]}
/>
)}
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
{/* {isActive && isKeyTime && (
<Animated.View
style={[styles.stepLabel, { opacity: animatedOpacity }]}
>
<Text style={styles.stepLabelText}>{data.steps}</Text>
</Animated.View>
)} */}
</View>
);
})}
</View>
{/* 底部时间轴标签 */}
<View style={styles.timeLabels}>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
</View>
</View>
</View>
{/* 活动等级展示卡片 */}
<View style={styles.activityLevelCard}>
{/* 活动级别文本 */}
<Text style={styles.activityMainText}>{t('stepsDetail.activityLevel.currentActivity')}</Text>
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
{/* 进度条 */}
<View style={styles.progressBarContainer}>
<View style={styles.progressBarBackground}>
<View
style={[
styles.progressBarFill,
{
width: `${progressPercentage}%`,
backgroundColor: currentActivityLevel.color
}
]}
/>
</View>
</View>
{/* 步数信息 */}
<View style={styles.stepsInfoContainer}>
<View style={styles.currentStepsInfo}>
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text>
<Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
</View>
<View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text>
<Text style={styles.stepsLabel}>
{nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
</Text>
</View>
</View>
{/* 活动等级图例 */}
<View style={styles.activityLegendContainer}>
{reversedActivityLevels.map((level) => (
<View key={level.key} style={styles.legendItem}>
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
<Text style={styles.legendIconText}>🏃</Text>
</View>
<Text style={styles.legendLabel}>{level.label}</Text>
<Text style={styles.legendRange}>
{level.maxSteps === Infinity
? `> ${level.minSteps.toLocaleString()}`
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
</Text>
</View>
))}
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: 'transparent',
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
headerRight: {
width: 40,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginVertical: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 24,
fontWeight: '700',
color: '#192126',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#64748B',
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
loadingText: {
fontSize: 16,
color: '#64748B',
},
chartCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
chartHeader: {
marginBottom: 20,
},
chartTitle: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
marginBottom: 4,
},
chartSubtitle: {
fontSize: 14,
color: '#64748B',
},
chartContainer: {
position: 'relative',
},
timeLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
paddingHorizontal: 8,
},
timeLabel: {
fontSize: 12,
color: '#64748B',
fontWeight: '500',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 120,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 8,
height: 120,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
backgroundBar: {
width: 8,
height: 120,
borderRadius: 2,
position: 'absolute',
bottom: 0,
},
dataBar: {
width: 8,
borderRadius: 2,
position: 'absolute',
bottom: 0,
},
stepLabel: {
position: 'absolute',
top: -20,
alignItems: 'center',
},
stepLabelText: {
fontSize: 10,
color: '#64748B',
fontWeight: '500',
},
averageLine: {
position: 'absolute',
left: 4, // 匹配 chartArea 的 paddingHorizontal
right: 4, // 匹配 chartArea 的 paddingHorizontal
flexDirection: 'row',
alignItems: 'center',
zIndex: 1,
},
averageLineDashContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
overflow: 'hidden', // 防止虚线段溢出容器
},
dashSegment: {
width: 3,
height: 1.5,
backgroundColor: '#FFA726',
opacity: 0.8,
},
averageLineLabel: {
fontSize: 10,
color: '#FFA726',
fontWeight: '600',
backgroundColor: '#FFFFFF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 0.5,
borderColor: '#FFA726',
},
activityLevelCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 24,
marginVertical: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
activityIconContainer: {
marginBottom: 16,
},
activityIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#E0F2FE',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#93C5FD',
borderStyle: 'dashed',
},
meditationIcon: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#93C5FD',
alignItems: 'center',
justifyContent: 'center',
},
meditationEmoji: {
fontSize: 24,
},
activityMainText: {
fontSize: 16,
color: '#64748B',
marginBottom: 4,
},
activityLevelText: {
fontSize: 24,
fontWeight: '700',
color: '#192126',
marginBottom: 20,
},
progressBarContainer: {
width: '100%',
marginBottom: 24,
},
progressBarBackground: {
width: '100%',
height: 8,
backgroundColor: '#F0F9FF',
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 4,
},
stepsInfoContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginBottom: 32,
},
currentStepsInfo: {
alignItems: 'flex-start',
},
nextStepsInfo: {
alignItems: 'flex-end',
},
stepsValue: {
fontSize: 20,
fontWeight: '700',
color: '#192126',
marginBottom: 4,
},
stepsLabel: {
fontSize: 14,
color: '#64748B',
},
activityLegendContainer: {
width: '100%',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: '#F8FAFC',
marginBottom: 8,
borderRadius: 12,
},
legendIcon: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
legendIconText: {
fontSize: 16,
},
legendLabel: {
flex: 1,
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
legendRange: {
fontSize: 14,
color: '#64748B',
fontWeight: '500',
},
});