From 741688065daa70223c0ddcbd094cf9e897b68842 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sat, 30 Aug 2025 17:07:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=AD=A5=E6=95=B0?= =?UTF-8?q?=E5=8D=A1=E7=89=87=EF=BC=9B=20=E4=BC=98=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=88=86=E6=9E=90=E5=90=84=E7=B1=BB=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/statistics.tsx | 141 ++++++++-------- components/MoodCard.tsx | 4 +- components/StepsCard.tsx | 152 ++++++++++++++++++ components/StressMeter.tsx | 17 +- components/statistic/HealthDataCard.tsx | 12 +- components/statistic/OxygenSaturationCard.tsx | 5 - store/healthSlice.ts | 2 + utils/health.ts | 96 ++++++++++- utils/mockHealthData.ts | 136 ++++++++++++++++ 9 files changed, 462 insertions(+), 103 deletions(-) create mode 100644 components/StepsCard.tsx create mode 100644 utils/mockHealthData.ts diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index ee4568b..e068ecc 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -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 ( - {children} - + ); }; 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(new Date()); @@ -357,38 +341,31 @@ export default function ExploreScreen() { 消耗卡路里 - {activeCalories != null ? ( - `${Math.round(v)} 千卡`} - /> - ) : ( - —— - )} + + {activeCalories != null ? ( + `${Math.round(v)}`} + /> + ) : ( + —— + )} + 千卡 + - - - 步数 - - {stepCount != null ? ( - `${Math.round(v)}/${stepGoal}`} - /> - ) : ( - ——/{stepGoal} - )} - + @@ -414,7 +391,7 @@ export default function ExploreScreen() { /> - + 睡眠 @@ -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, }, diff --git a/components/MoodCard.tsx b/components/MoodCard.tsx index 2f17185..7562270 100644 --- a/components/MoodCard.tsx +++ b/components/MoodCard.tsx @@ -15,8 +15,6 @@ export function MoodCard({ moodCheckin, onPress, isLoading = false }: MoodCardPr return ( 心情 - - {isLoading ? ( 加载中... @@ -52,7 +50,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginTop: 22, + marginTop: 12, }, moodPreviewText: { fontSize: 14, diff --git a/components/StepsCard.tsx b/components/StepsCard.tsx new file mode 100644 index 0000000..4683591 --- /dev/null +++ b/components/StepsCard.tsx @@ -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 = ({ + 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 ( + + {/* 标题和步数显示 */} + + 步数 + + + {/* 柱状图 */} + + + + {chartData.map((data, index) => { + // 判断是否是当前小时或者有活动的小时 + const isActive = data.steps > 0; + const isCurrent = index <= currentHour; + + return ( + + ); + })} + + + + + {/* 步数和目标显示 */} + + + {stepCount !== null ? stepCount.toLocaleString() : '——'} + + + + ); +}; + +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; \ No newline at end of file diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx index d7bd12a..ff4575b 100644 --- a/components/StressMeter.tsx +++ b/components/StressMeter.tsx @@ -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', diff --git a/components/statistic/HealthDataCard.tsx b/components/statistic/HealthDataCard.tsx index 81d2615..510b7af 100644 --- a/components/statistic/HealthDataCard.tsx +++ b/components/statistic/HealthDataCard.tsx @@ -34,7 +34,6 @@ const HealthDataCard: React.FC = ({ 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: { diff --git a/components/statistic/OxygenSaturationCard.tsx b/components/statistic/OxygenSaturationCard.tsx index fbc55e3..57b1028 100644 --- a/components/statistic/OxygenSaturationCard.tsx +++ b/components/statistic/OxygenSaturationCard.tsx @@ -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 = ({ style, oxygenSaturation }) => { - const oxygenIcon = ( - - ); - return ( { @@ -155,6 +162,88 @@ async function fetchStepCount(date: Date): Promise { }); } +// 获取指定日期每小时步数数据 +async function fetchHourlyStepCount(date: Date): Promise { + 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 { + 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 { 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 { + 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, + }; +}; \ No newline at end of file