Files
digital-pilates/app/steps/detail.tsx
richarjiang a70cb1e407 feat: 新增步数详情页面,支持日期选择和步数统计展示
feat: 更新StepsCard组件,支持点击事件回调
feat: 在WaterIntakeCard中添加震动反馈功能
fix: 在用户重建时保存authToken
2025-09-02 19:22:02 +08:00

544 lines
16 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]);
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>
</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',
},
});