feat: 支持步数卡片; 优化数据分析各类卡片样式
This commit is contained in:
@@ -4,9 +4,9 @@ import { DateSelector } from '@/components/DateSelector';
|
|||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import { ProgressBar } from '@/components/ProgressBar';
|
|
||||||
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
|
import StepsCard from '@/components/StepsCard';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
@@ -19,13 +19,13 @@ import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/mo
|
|||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||||
|
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -40,53 +40,28 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
style?: any;
|
style?: any;
|
||||||
}) => {
|
}) => {
|
||||||
const floatAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// const startAnimation = () => {
|
|
||||||
// Animated.loop(
|
|
||||||
// Animated.sequence([
|
|
||||||
// Animated.timing(floatAnim, {
|
|
||||||
// toValue: 1,
|
|
||||||
// duration: 3000,
|
|
||||||
// delay: delay,
|
|
||||||
// useNativeDriver: true,
|
|
||||||
// }),
|
|
||||||
// Animated.timing(floatAnim, {
|
|
||||||
// toValue: 0,
|
|
||||||
// duration: 3000,
|
|
||||||
// useNativeDriver: true,
|
|
||||||
// }),
|
|
||||||
// ])
|
|
||||||
// ).start();
|
|
||||||
// };
|
|
||||||
|
|
||||||
// startAnimation();
|
|
||||||
// }, [floatAnim, delay]);
|
|
||||||
|
|
||||||
const translateY = floatAnim.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [-2, -6],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[
|
||||||
style,
|
style,
|
||||||
{
|
{
|
||||||
transform: [{ translateY }],
|
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
|
|
||||||
|
// 开发调试:设置为true来使用mock数据
|
||||||
|
const useMockData = true; // 改为true来启用mock数据调试
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
// 使用 dayjs:当月日期与默认选中"今天"
|
// 使用 dayjs:当月日期与默认选中"今天"
|
||||||
@@ -111,15 +86,24 @@ export default function ExploreScreen() {
|
|||||||
// 从 Redux 获取指定日期的健康数据
|
// 从 Redux 获取指定日期的健康数据
|
||||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||||
|
|
||||||
// 解构健康数据
|
// 解构健康数据(支持mock数据)
|
||||||
const stepCount = healthData?.steps ?? null;
|
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||||
const activeCalories = healthData?.activeEnergyBurned ?? null;
|
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
||||||
const basalMetabolism = healthData?.basalEnergyBurned ?? null;
|
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
||||||
const sleepDuration = healthData?.sleepDuration ?? null;
|
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||||
const hrvValue = healthData?.hrv ?? 0;
|
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||||
const oxygenSaturation = healthData?.oxygenSaturation ?? null;
|
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
|
||||||
const heartRate = healthData?.heartRate ?? null;
|
const hrvValue = useMockData ? (mockData?.hrv ?? 0) : (healthData?.hrv ?? 0);
|
||||||
const fitnessRingsData = healthData ? {
|
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||||
|
const heartRate = useMockData ? (mockData?.heartRate ?? null) : (healthData?.heartRate ?? null);
|
||||||
|
const fitnessRingsData = useMockData ? {
|
||||||
|
activeCalories: mockData?.activeCalories ?? 0,
|
||||||
|
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||||
|
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
|
||||||
|
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
|
||||||
|
standHours: mockData?.standHours ?? 0,
|
||||||
|
standHoursGoal: mockData?.standHoursGoal ?? 12,
|
||||||
|
} : (healthData ? {
|
||||||
activeCalories: healthData.activeEnergyBurned,
|
activeCalories: healthData.activeEnergyBurned,
|
||||||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||||||
exerciseMinutes: healthData.exerciseMinutes,
|
exerciseMinutes: healthData.exerciseMinutes,
|
||||||
@@ -133,7 +117,7 @@ export default function ExploreScreen() {
|
|||||||
exerciseMinutesGoal: 30,
|
exerciseMinutesGoal: 30,
|
||||||
standHours: 0,
|
standHours: 0,
|
||||||
standHoursGoal: 12,
|
standHoursGoal: 12,
|
||||||
};
|
});
|
||||||
|
|
||||||
// HRV更新时间
|
// HRV更新时间
|
||||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||||
@@ -357,38 +341,31 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
<FloatingCard style={styles.masonryCard} delay={500}>
|
||||||
<Text style={styles.cardTitle}>消耗卡路里</Text>
|
<Text style={styles.cardTitle}>消耗卡路里</Text>
|
||||||
{activeCalories != null ? (
|
<View style={{
|
||||||
<AnimatedNumber
|
flexDirection: 'row',
|
||||||
value={activeCalories}
|
alignItems: 'flex-end',
|
||||||
resetToken={animToken}
|
marginTop: 20
|
||||||
style={styles.caloriesValue}
|
}}>
|
||||||
format={(v) => `${Math.round(v)} 千卡`}
|
{activeCalories != null ? (
|
||||||
/>
|
<AnimatedNumber
|
||||||
) : (
|
value={activeCalories}
|
||||||
<Text style={styles.caloriesValue}>——</Text>
|
resetToken={animToken}
|
||||||
)}
|
style={styles.caloriesValue}
|
||||||
|
format={(v) => `${Math.round(v)}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.caloriesValue}>——</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.caloriesUnit}>千卡</Text>
|
||||||
|
</View>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard} delay={1000}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<View style={styles.cardHeaderRow}>
|
<StepsCard
|
||||||
<Text style={styles.cardTitle}>步数</Text>
|
stepCount={stepCount}
|
||||||
</View>
|
stepGoal={stepGoal}
|
||||||
{stepCount != null ? (
|
hourlySteps={hourlySteps}
|
||||||
<AnimatedNumber
|
style={styles.stepsCardOverride}
|
||||||
value={stepCount}
|
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.stepsValue}
|
|
||||||
format={(v) => `${Math.round(v)}/${stepGoal}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.stepsValue}>——/{stepGoal}</Text>
|
|
||||||
)}
|
|
||||||
<ProgressBar
|
|
||||||
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
|
|
||||||
height={14}
|
|
||||||
trackColor="#FFEBCB"
|
|
||||||
fillColor="#FFC365"
|
|
||||||
showLabel={false}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||||
@@ -414,7 +391,7 @@ export default function ExploreScreen() {
|
|||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard} delay={750}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<View style={styles.cardHeaderRow}>
|
<View style={styles.cardHeaderRow}>
|
||||||
<Text style={styles.cardTitle}>睡眠</Text>
|
<Text style={styles.cardTitle}>睡眠</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -463,8 +440,6 @@ export default function ExploreScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primary = Colors.light.primary;
|
const primary = Colors.light.primary;
|
||||||
const lightColors = Colors.light;
|
|
||||||
const darkColors = Colors.dark;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -543,8 +518,15 @@ const styles = StyleSheet.create({
|
|||||||
caloriesValue: {
|
caloriesValue: {
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '800',
|
lineHeight: 18,
|
||||||
marginTop: 18,
|
fontWeight: '600',
|
||||||
|
textAlignVertical: 'bottom'
|
||||||
|
},
|
||||||
|
caloriesUnit: {
|
||||||
|
color: '#515558ff',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
trainingContent: {
|
trainingContent: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -737,6 +719,11 @@ const styles = StyleSheet.create({
|
|||||||
margin: -16, // 抵消 masonryCard 的 padding
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
},
|
},
|
||||||
|
stepsCardOverride: {
|
||||||
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
|
borderRadius: 16,
|
||||||
|
height: '100%', // 填充整个masonryCard
|
||||||
|
},
|
||||||
compactStepsCard: {
|
compactStepsCard: {
|
||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export function MoodCard({ moodCheckin, onPress, isLoading = false }: MoodCardPr
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}>
|
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}>
|
||||||
<Text style={styles.cardTitle}>心情</Text>
|
<Text style={styles.cardTitle}>心情</Text>
|
||||||
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={styles.moodPreview}>
|
<View style={styles.moodPreview}>
|
||||||
<Text style={styles.moodLoadingText}>加载中...</Text>
|
<Text style={styles.moodLoadingText}>加载中...</Text>
|
||||||
@@ -52,7 +50,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 22,
|
marginTop: 12,
|
||||||
},
|
},
|
||||||
moodPreviewText: {
|
moodPreviewText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
152
components/StepsCard.tsx
Normal file
152
components/StepsCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
ViewStyle
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { HourlyStepData } from '@/utils/health';
|
||||||
|
// 使用原生View来替代SVG,避免导入问题
|
||||||
|
// import Svg, { Rect } from 'react-native-svg';
|
||||||
|
|
||||||
|
interface StepsCardProps {
|
||||||
|
stepCount: number | null;
|
||||||
|
stepGoal: number;
|
||||||
|
hourlySteps: HourlyStepData[];
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepsCard: React.FC<StepsCardProps> = ({
|
||||||
|
stepCount,
|
||||||
|
stepGoal,
|
||||||
|
hourlySteps,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
// 计算柱状图数据
|
||||||
|
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 = 20; // 柱状图最大高度(缩小一半)
|
||||||
|
|
||||||
|
return hourlySteps.map(data => ({
|
||||||
|
...data,
|
||||||
|
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||||
|
}));
|
||||||
|
}, [hourlySteps]);
|
||||||
|
|
||||||
|
// 获取当前小时
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
{/* 标题和步数显示 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>步数</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 柱状图 */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<View style={styles.chartWrapper}>
|
||||||
|
<View style={styles.chartArea}>
|
||||||
|
{chartData.map((data, index) => {
|
||||||
|
// 判断是否是当前小时或者有活动的小时
|
||||||
|
const isActive = data.steps > 0;
|
||||||
|
const isCurrent = index <= currentHour;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`bar-${index}`}
|
||||||
|
style={[
|
||||||
|
styles.chartBar,
|
||||||
|
{
|
||||||
|
height: data.height || 2, // 最小高度2px
|
||||||
|
backgroundColor: isCurrent && isActive ? '#FFC365' : '#FFEBCB',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 步数和目标显示 */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<Text style={styles.stepCount}>
|
||||||
|
{stepCount !== null ? stepCount.toLocaleString() : '——'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
footprintIcons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
chartWrapper: {
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartArea: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 20,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 240,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: 4,
|
||||||
|
borderRadius: 1,
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginTop: 6
|
||||||
|
},
|
||||||
|
stepCount: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StepsCard;
|
||||||
@@ -154,10 +154,11 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
fontSize: 24,
|
fontSize: 20,
|
||||||
fontWeight: '800',
|
fontWeight: '600',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
lineHeight: 32,
|
lineHeight: 20,
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
unit: {
|
unit: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -166,10 +167,10 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
progressContainer: {
|
progressContainer: {
|
||||||
height: 16,
|
height: 6,
|
||||||
},
|
},
|
||||||
progressTrack: {
|
progressTrack: {
|
||||||
height: 8,
|
height: 6,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
@@ -185,9 +186,9 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
indicator: {
|
indicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -4,
|
top: -2,
|
||||||
width: 16,
|
width: 10,
|
||||||
height: 16,
|
height: 10,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
borderRadius: 16,
|
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
@@ -48,11 +47,6 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
iconContainer: {
|
|
||||||
marginRight: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -60,7 +54,7 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
marginBottom: 4,
|
marginBottom: 14,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
valueContainer: {
|
valueContainer: {
|
||||||
@@ -68,8 +62,8 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
fontSize: 24,
|
fontSize: 20,
|
||||||
fontWeight: '800',
|
fontWeight: '600',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
},
|
},
|
||||||
unit: {
|
unit: {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import HealthDataCard from './HealthDataCard';
|
import HealthDataCard from './HealthDataCard';
|
||||||
@@ -14,10 +13,6 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
|||||||
style,
|
style,
|
||||||
oxygenSaturation
|
oxygenSaturation
|
||||||
}) => {
|
}) => {
|
||||||
const oxygenIcon = (
|
|
||||||
<Ionicons name="water" size={24} color="#3B82F6" />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HealthDataCard
|
<HealthDataCard
|
||||||
title="血氧饱和度"
|
title="血氧饱和度"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { AppDispatch, RootState } from './index';
|
import { AppDispatch, RootState } from './index';
|
||||||
|
import { HourlyStepData } from '@/utils/health';
|
||||||
|
|
||||||
// 健康数据类型定义
|
// 健康数据类型定义
|
||||||
export interface FitnessRingsData {
|
export interface FitnessRingsData {
|
||||||
@@ -25,6 +26,7 @@ export interface HealthData {
|
|||||||
exerciseMinutesGoal: number;
|
exerciseMinutesGoal: number;
|
||||||
standHours: number;
|
standHours: number;
|
||||||
standHoursGoal: number;
|
standHoursGoal: number;
|
||||||
|
hourlySteps: HourlyStepData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HealthState {
|
export interface HealthState {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const PERMISSIONS: HealthKitPermissions = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HourlyStepData = {
|
||||||
|
hour: number; // 0-23
|
||||||
|
steps: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TodayHealthData = {
|
export type TodayHealthData = {
|
||||||
steps: number;
|
steps: number;
|
||||||
activeEnergyBurned: number; // kilocalories
|
activeEnergyBurned: number; // kilocalories
|
||||||
@@ -43,6 +48,8 @@ export type TodayHealthData = {
|
|||||||
// 新增血氧饱和度和心率数据
|
// 新增血氧饱和度和心率数据
|
||||||
oxygenSaturation: number | null;
|
oxygenSaturation: number | null;
|
||||||
heartRate: number | null;
|
heartRate: number | null;
|
||||||
|
// 每小时步数数据
|
||||||
|
hourlySteps: HourlyStepData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function ensureHealthPermissions(): Promise<boolean> {
|
export async function ensureHealthPermissions(): Promise<boolean> {
|
||||||
@@ -155,6 +162,88 @@ async function fetchStepCount(date: Date): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取指定日期每小时步数数据
|
||||||
|
async function fetchHourlyStepCount(date: Date): Promise<HourlyStepData[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startOfDay = dayjs(date).startOf('day');
|
||||||
|
const endOfDay = dayjs(date).endOf('day');
|
||||||
|
|
||||||
|
AppleHealthKit.getStepCount({
|
||||||
|
startDate: startOfDay.toDate().toISOString(),
|
||||||
|
endDate: endOfDay.toDate().toISOString(),
|
||||||
|
includeManuallyAdded: false,
|
||||||
|
}, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
logError('每小时步数', err);
|
||||||
|
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
|
logWarning('每小时步数', '为空');
|
||||||
|
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
logSuccess('每小时步数', res);
|
||||||
|
|
||||||
|
// 初始化24小时数据
|
||||||
|
const hourlyData: HourlyStepData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
hour: i,
|
||||||
|
steps: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 如果返回的是累计数据,我们需要获取样本数据
|
||||||
|
resolve(hourlyData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用样本数据获取每小时步数
|
||||||
|
async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const startOfDay = dayjs(date).startOf('day');
|
||||||
|
const endOfDay = dayjs(date).endOf('day');
|
||||||
|
|
||||||
|
AppleHealthKit.getSamples(
|
||||||
|
{
|
||||||
|
startDate: startOfDay.toDate().toISOString(),
|
||||||
|
endDate: endOfDay.toDate().toISOString(),
|
||||||
|
type: 'StepCount',
|
||||||
|
},
|
||||||
|
(err: any, res: any[]) => {
|
||||||
|
if (err) {
|
||||||
|
logError('每小时步数样本', err);
|
||||||
|
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
|
logWarning('每小时步数样本', '为空');
|
||||||
|
return resolve(Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
logSuccess('每小时步数样本', res);
|
||||||
|
|
||||||
|
// 初始化24小时数据
|
||||||
|
const hourlyData: HourlyStepData[] = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
hour: i,
|
||||||
|
steps: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 将样本数据按小时分组并累加
|
||||||
|
res.forEach((sample: any) => {
|
||||||
|
if (sample && sample.startDate && sample.value) {
|
||||||
|
const hour = dayjs(sample.startDate).hour();
|
||||||
|
if (hour >= 0 && hour < 24) {
|
||||||
|
hourlyData[hour].steps += Math.round(sample.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(hourlyData);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
||||||
@@ -304,7 +393,8 @@ function getDefaultHealthData(): TodayHealthData {
|
|||||||
standHours: 0,
|
standHours: 0,
|
||||||
standHoursGoal: 12,
|
standHoursGoal: 12,
|
||||||
oxygenSaturation: null,
|
oxygenSaturation: null,
|
||||||
heartRate: null
|
heartRate: null,
|
||||||
|
hourlySteps: Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0 }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +408,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
// 并行获取所有健康数据
|
// 并行获取所有健康数据
|
||||||
const [
|
const [
|
||||||
steps,
|
steps,
|
||||||
|
hourlySteps,
|
||||||
activeEnergyBurned,
|
activeEnergyBurned,
|
||||||
basalEnergyBurned,
|
basalEnergyBurned,
|
||||||
sleepDuration,
|
sleepDuration,
|
||||||
@@ -327,6 +418,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
heartRate
|
heartRate
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchStepCount(date),
|
fetchStepCount(date),
|
||||||
|
fetchHourlyStepSamples(date),
|
||||||
fetchActiveEnergyBurned(options),
|
fetchActiveEnergyBurned(options),
|
||||||
fetchBasalEnergyBurned(options),
|
fetchBasalEnergyBurned(options),
|
||||||
fetchSleepDuration(options),
|
fetchSleepDuration(options),
|
||||||
@@ -338,6 +430,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
|
|
||||||
console.log('指定日期健康数据获取完成:', {
|
console.log('指定日期健康数据获取完成:', {
|
||||||
steps,
|
steps,
|
||||||
|
hourlySteps,
|
||||||
activeEnergyBurned,
|
activeEnergyBurned,
|
||||||
basalEnergyBurned,
|
basalEnergyBurned,
|
||||||
sleepDuration,
|
sleepDuration,
|
||||||
@@ -349,6 +442,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
steps,
|
steps,
|
||||||
|
hourlySteps,
|
||||||
activeEnergyBurned,
|
activeEnergyBurned,
|
||||||
basalEnergyBurned,
|
basalEnergyBurned,
|
||||||
sleepDuration,
|
sleepDuration,
|
||||||
|
|||||||
136
utils/mockHealthData.ts
Normal file
136
utils/mockHealthData.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { HourlyStepData, TodayHealthData } from './health';
|
||||||
|
|
||||||
|
// Mock的每小时步数数据,模拟真实的一天活动模式
|
||||||
|
export const mockHourlySteps: HourlyStepData[] = [
|
||||||
|
{ hour: 0, steps: 0 }, // 午夜
|
||||||
|
{ hour: 1, steps: 0 }, // 凌晨
|
||||||
|
{ hour: 2, steps: 0 },
|
||||||
|
{ hour: 3, steps: 0 },
|
||||||
|
{ hour: 4, steps: 0 },
|
||||||
|
{ hour: 5, steps: 0 },
|
||||||
|
{ hour: 6, steps: 120 }, // 早晨起床
|
||||||
|
{ hour: 7, steps: 450 }, // 晨练/上班准备
|
||||||
|
{ hour: 8, steps: 680 }, // 上班通勤
|
||||||
|
{ hour: 9, steps: 320 }, // 工作时间
|
||||||
|
{ hour: 10, steps: 180 }, // 办公室内活动
|
||||||
|
{ hour: 11, steps: 280 }, // 会议/活动
|
||||||
|
{ hour: 12, steps: 520 }, // 午餐时间
|
||||||
|
{ hour: 13, steps: 150 }, // 午休
|
||||||
|
{ hour: 14, steps: 240 }, // 下午工作
|
||||||
|
{ hour: 15, steps: 300 }, // 工作活动
|
||||||
|
{ hour: 16, steps: 380 }, // 会议/外出
|
||||||
|
{ hour: 17, steps: 480 }, // 下班通勤
|
||||||
|
{ hour: 18, steps: 620 }, // 晚餐/活动
|
||||||
|
{ hour: 19, steps: 350 }, // 晚间活动
|
||||||
|
{ hour: 20, steps: 280 }, // 散步
|
||||||
|
{ hour: 21, steps: 150 }, // 休闲时间
|
||||||
|
{ hour: 22, steps: 80 }, // 准备睡觉
|
||||||
|
{ hour: 23, steps: 30 }, // 睡前
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock的完整健康数据
|
||||||
|
export const mockHealthData: TodayHealthData = {
|
||||||
|
steps: 6140, // 总步数
|
||||||
|
hourlySteps: mockHourlySteps,
|
||||||
|
activeEnergyBurned: 420,
|
||||||
|
basalEnergyBurned: 1680,
|
||||||
|
sleepDuration: 480, // 8小时
|
||||||
|
hrv: 45,
|
||||||
|
activeCalories: 420,
|
||||||
|
activeCaloriesGoal: 350,
|
||||||
|
exerciseMinutes: 32,
|
||||||
|
exerciseMinutesGoal: 30,
|
||||||
|
standHours: 8,
|
||||||
|
standHoursGoal: 12,
|
||||||
|
oxygenSaturation: 98.2,
|
||||||
|
heartRate: 72,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成随机的每小时步数数据(用于测试不同的数据模式)
|
||||||
|
export const generateRandomHourlySteps = (): HourlyStepData[] => {
|
||||||
|
return Array.from({ length: 24 }, (_, hour) => {
|
||||||
|
let steps = 0;
|
||||||
|
|
||||||
|
// 模拟真实的活动模式
|
||||||
|
if (hour >= 6 && hour <= 22) {
|
||||||
|
if (hour >= 7 && hour <= 9) {
|
||||||
|
// 早晨高峰期
|
||||||
|
steps = Math.floor(Math.random() * 600) + 200;
|
||||||
|
} else if (hour >= 12 && hour <= 13) {
|
||||||
|
// 午餐时间
|
||||||
|
steps = Math.floor(Math.random() * 400) + 300;
|
||||||
|
} else if (hour >= 17 && hour <= 19) {
|
||||||
|
// 晚间活跃期
|
||||||
|
steps = Math.floor(Math.random() * 500) + 250;
|
||||||
|
} else if (hour >= 6 && hour <= 22) {
|
||||||
|
// 白天正常活动
|
||||||
|
steps = Math.floor(Math.random() * 300) + 50;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 夜间很少活动
|
||||||
|
steps = Math.floor(Math.random() * 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hour, steps };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 不同活动模式的预设数据
|
||||||
|
export const activityPatterns = {
|
||||||
|
// 久坐办公族
|
||||||
|
sedentary: Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
hour,
|
||||||
|
steps: hour >= 7 && hour <= 18 ? Math.floor(Math.random() * 200) + 50 :
|
||||||
|
hour >= 19 && hour <= 21 ? Math.floor(Math.random() * 300) + 100 :
|
||||||
|
Math.floor(Math.random() * 20)
|
||||||
|
})),
|
||||||
|
|
||||||
|
// 活跃用户
|
||||||
|
active: Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
hour,
|
||||||
|
steps: hour >= 6 && hour <= 8 ? Math.floor(Math.random() * 800) + 400 :
|
||||||
|
hour >= 12 && hour <= 13 ? Math.floor(Math.random() * 600) + 300 :
|
||||||
|
hour >= 17 && hour <= 20 ? Math.floor(Math.random() * 900) + 500 :
|
||||||
|
hour >= 9 && hour <= 16 ? Math.floor(Math.random() * 400) + 100 :
|
||||||
|
Math.floor(Math.random() * 50)
|
||||||
|
})),
|
||||||
|
|
||||||
|
// 健身爱好者
|
||||||
|
fitness: Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
hour,
|
||||||
|
steps: hour === 6 ? Math.floor(Math.random() * 1200) + 800 : // 晨跑
|
||||||
|
hour === 18 ? Math.floor(Math.random() * 1000) + 600 : // 晚间锻炼
|
||||||
|
hour >= 7 && hour <= 17 ? Math.floor(Math.random() * 300) + 100 :
|
||||||
|
Math.floor(Math.random() * 50)
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用于快速切换测试数据的函数
|
||||||
|
export const getTestHealthData = (pattern: 'mock' | 'random' | 'sedentary' | 'active' | 'fitness' = 'mock'): TodayHealthData => {
|
||||||
|
let hourlySteps: HourlyStepData[];
|
||||||
|
|
||||||
|
switch (pattern) {
|
||||||
|
case 'random':
|
||||||
|
hourlySteps = generateRandomHourlySteps();
|
||||||
|
break;
|
||||||
|
case 'sedentary':
|
||||||
|
hourlySteps = activityPatterns.sedentary;
|
||||||
|
break;
|
||||||
|
case 'active':
|
||||||
|
hourlySteps = activityPatterns.active;
|
||||||
|
break;
|
||||||
|
case 'fitness':
|
||||||
|
hourlySteps = activityPatterns.fitness;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
hourlySteps = mockHourlySteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSteps = hourlySteps.reduce((sum, data) => sum + data.steps, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mockHealthData,
|
||||||
|
steps: totalSteps,
|
||||||
|
hourlySteps,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user