feat: 支持步数卡片; 优化数据分析各类卡片样式
This commit is contained in:
@@ -4,9 +4,9 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
@@ -19,13 +19,13 @@ import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/mo
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -40,53 +40,28 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
||||
delay?: number;
|
||||
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 (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
transform: [{ translateY }],
|
||||
marginBottom: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
const useMockData = true; // 改为true来启用mock数据调试
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
@@ -111,15 +86,24 @@ export default function ExploreScreen() {
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
|
||||
// 解构健康数据
|
||||
const stepCount = healthData?.steps ?? null;
|
||||
const activeCalories = healthData?.activeEnergyBurned ?? null;
|
||||
const basalMetabolism = healthData?.basalEnergyBurned ?? null;
|
||||
const sleepDuration = healthData?.sleepDuration ?? null;
|
||||
const hrvValue = healthData?.hrv ?? 0;
|
||||
const oxygenSaturation = healthData?.oxygenSaturation ?? null;
|
||||
const heartRate = healthData?.heartRate ?? null;
|
||||
const fitnessRingsData = healthData ? {
|
||||
// 解构健康数据(支持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 activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
|
||||
const hrvValue = useMockData ? (mockData?.hrv ?? 0) : (healthData?.hrv ?? 0);
|
||||
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,
|
||||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||||
exerciseMinutes: healthData.exerciseMinutes,
|
||||
@@ -133,7 +117,7 @@ export default function ExploreScreen() {
|
||||
exerciseMinutesGoal: 30,
|
||||
standHours: 0,
|
||||
standHoursGoal: 12,
|
||||
};
|
||||
});
|
||||
|
||||
// HRV更新时间
|
||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||
@@ -357,38 +341,31 @@ export default function ExploreScreen() {
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||||
<Text style={styles.cardTitle}>消耗卡路里</Text>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 20
|
||||
}}>
|
||||
{activeCalories != null ? (
|
||||
<AnimatedNumber
|
||||
value={activeCalories}
|
||||
resetToken={animToken}
|
||||
style={styles.caloriesValue}
|
||||
format={(v) => `${Math.round(v)} 千卡`}
|
||||
format={(v) => `${Math.round(v)}`}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.caloriesValue}>——</Text>
|
||||
)}
|
||||
<Text style={styles.caloriesUnit}>千卡</Text>
|
||||
</View>
|
||||
</FloatingCard>
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={1000}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>步数</Text>
|
||||
</View>
|
||||
{stepCount != null ? (
|
||||
<AnimatedNumber
|
||||
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 style={styles.masonryCard}>
|
||||
<StepsCard
|
||||
stepCount={stepCount}
|
||||
stepGoal={stepGoal}
|
||||
hourlySteps={hourlySteps}
|
||||
style={styles.stepsCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||
@@ -414,7 +391,7 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={750}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
@@ -463,8 +440,6 @@ export default function ExploreScreen() {
|
||||
}
|
||||
|
||||
const primary = Colors.light.primary;
|
||||
const lightColors = Colors.light;
|
||||
const darkColors = Colors.dark;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -543,8 +518,15 @@ const styles = StyleSheet.create({
|
||||
caloriesValue: {
|
||||
color: '#192126',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginTop: 18,
|
||||
lineHeight: 18,
|
||||
fontWeight: '600',
|
||||
textAlignVertical: 'bottom'
|
||||
},
|
||||
caloriesUnit: {
|
||||
color: '#515558ff',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
lineHeight: 18,
|
||||
},
|
||||
trainingContent: {
|
||||
marginTop: 8,
|
||||
@@ -737,6 +719,11 @@ const styles = StyleSheet.create({
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
},
|
||||
stepsCardOverride: {
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
height: '100%', // 填充整个masonryCard
|
||||
},
|
||||
compactStepsCard: {
|
||||
minHeight: 100,
|
||||
},
|
||||
|
||||
@@ -15,8 +15,6 @@ export function MoodCard({ moodCheckin, onPress, isLoading = false }: MoodCardPr
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} disabled={isLoading}>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.moodPreview}>
|
||||
<Text style={styles.moodLoadingText}>加载中...</Text>
|
||||
@@ -52,7 +50,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 22,
|
||||
marginTop: 12,
|
||||
},
|
||||
moodPreviewText: {
|
||||
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,
|
||||
},
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
lineHeight: 32,
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
},
|
||||
unit: {
|
||||
fontSize: 12,
|
||||
@@ -166,10 +167,10 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 4,
|
||||
},
|
||||
progressContainer: {
|
||||
height: 16,
|
||||
height: 6,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 8,
|
||||
height: 6,
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
@@ -185,9 +186,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
indicator: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
width: 16,
|
||||
height: 16,
|
||||
top: -2,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
|
||||
@@ -34,7 +34,6 @@ const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
paddingHorizontal: 16,
|
||||
shadowOffset: {
|
||||
@@ -48,11 +47,6 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
@@ -60,7 +54,7 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
marginBottom: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
valueContainer: {
|
||||
@@ -68,8 +62,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
unit: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
@@ -14,10 +13,6 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
style,
|
||||
oxygenSaturation
|
||||
}) => {
|
||||
const oxygenIcon = (
|
||||
<Ionicons name="water" size={24} color="#3B82F6" />
|
||||
);
|
||||
|
||||
return (
|
||||
<HealthDataCard
|
||||
title="血氧饱和度"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { AppDispatch, RootState } from './index';
|
||||
import { HourlyStepData } from '@/utils/health';
|
||||
|
||||
// 健康数据类型定义
|
||||
export interface FitnessRingsData {
|
||||
@@ -25,6 +26,7 @@ export interface HealthData {
|
||||
exerciseMinutesGoal: number;
|
||||
standHours: number;
|
||||
standHoursGoal: number;
|
||||
hourlySteps: HourlyStepData[];
|
||||
}
|
||||
|
||||
export interface HealthState {
|
||||
|
||||
@@ -27,6 +27,11 @@ const PERMISSIONS: HealthKitPermissions = {
|
||||
},
|
||||
};
|
||||
|
||||
export type HourlyStepData = {
|
||||
hour: number; // 0-23
|
||||
steps: number;
|
||||
};
|
||||
|
||||
export type TodayHealthData = {
|
||||
steps: number;
|
||||
activeEnergyBurned: number; // kilocalories
|
||||
@@ -43,6 +48,8 @@ export type TodayHealthData = {
|
||||
// 新增血氧饱和度和心率数据
|
||||
oxygenSaturation: number | null;
|
||||
heartRate: number | null;
|
||||
// 每小时步数数据
|
||||
hourlySteps: HourlyStepData[];
|
||||
};
|
||||
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
||||
@@ -304,7 +393,8 @@ function getDefaultHealthData(): TodayHealthData {
|
||||
standHours: 0,
|
||||
standHoursGoal: 12,
|
||||
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 [
|
||||
steps,
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
sleepDuration,
|
||||
@@ -327,6 +418,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
heartRate
|
||||
] = await Promise.all([
|
||||
fetchStepCount(date),
|
||||
fetchHourlyStepSamples(date),
|
||||
fetchActiveEnergyBurned(options),
|
||||
fetchBasalEnergyBurned(options),
|
||||
fetchSleepDuration(options),
|
||||
@@ -338,6 +430,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
|
||||
console.log('指定日期健康数据获取完成:', {
|
||||
steps,
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
sleepDuration,
|
||||
@@ -349,6 +442,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
|
||||
return {
|
||||
steps,
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
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