Files
digital-pilates/app/steps/detail.tsx

757 lines
22 KiB
TypeScript
Raw 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 { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function StepsDetailScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
// 开发调试设置为true来使用mock数据
const useMockData = __DEV__;
// 日期选择相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
// 数据加载状态
const [isLoading, setIsLoading] = useState(false);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
// 解构健康数据支持mock数据
const mockData = useMockData ? getTestHealthData('mock') : null;
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
// 为每个柱体创建独立的动画值
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 loadHealthData = async (targetDate: Date) => {
if (useMockData) return; // 如果使用mock数据不需要加载
try {
setIsLoading(true);
console.log('加载步数详情数据...', targetDate);
const ok = await ensureHealthPermissions();
if (!ok) {
console.warn('无法获取健康权限');
return;
}
const data = await fetchHealthDataForDate(targetDate);
console.log('data', data);
const dateString = dayjs(targetDate).format('YYYY-MM-DD');
// 使用 Redux 存储健康数据
dispatch(setHealthData({
date: dateString,
data: data
}));
console.log('步数详情数据加载完成');
} catch (error) {
console.error('加载步数详情数据失败:', error);
} finally {
setIsLoading(false);
}
};
// 日期选择处理
const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index);
loadHealthData(date);
};
// 页面初始化时加载当前日期数据
useEffect(() => {
loadHealthData(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: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], []);
// 计算当前活动等级
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 }}
/>
<SafeAreaView style={styles.safeArea}>
{/* 顶部导航栏 */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerRight} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
{/* 统计卡片 */}
<View style={styles.statsCard}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
)}
</View>
{/* 详细柱状图卡片 */}
<View style={styles.chartCard}>
<View style={styles.chartHeader}>
<Text style={styles.chartTitle}></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}>
{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}>0:00</Text>
<Text style={styles.timeLabel}>12:00</Text>
<Text style={styles.timeLabel}>24:00</Text>
</View>
</View>
</View>
{/* 活动等级展示卡片 */}
<View style={styles.activityLevelCard}>
{/* 活动级别文本 */}
<Text style={styles.activityMainText}></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}></Text>
</View>
<View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text>
<Text style={styles.stepsLabel}>
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
</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>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
safeArea: {
flex: 1,
},
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',
},
});