refactor(sleep): 重构睡眠数据获取逻辑,移除冗余代码并优化组件结构

- 从 healthSlice 和 health.ts 中移除 sleepDuration 字段及相关获取逻辑
- 将 SleepCard 改为按需异步获取睡眠数据,支持传入指定日期
- 睡眠详情页改为通过路由参数接收日期,支持查看历史记录
- 移除 statistics 页面对 sleepDuration 的直接依赖,统一由 SleepCard 管理
- 删除未使用的 SleepStageChart 组件,简化页面结构
This commit is contained in:
richarjiang
2025-09-11 09:08:51 +08:00
parent aee87e8900
commit 62690ee3fc
5 changed files with 43 additions and 181 deletions

View File

@@ -98,7 +98,6 @@ export default function ExploreScreen() {
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []); const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null); const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? 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 ?? null) : (healthData?.hrv ?? null); const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null); const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
@@ -293,7 +292,6 @@ export default function ExploreScreen() {
steps: data.steps, steps: data.steps,
activeCalories: data.activeEnergyBurned, activeCalories: data.activeEnergyBurned,
basalEnergyBurned: data.basalEnergyBurned, basalEnergyBurned: data.basalEnergyBurned,
sleepDuration: data.sleepDuration,
hrv: data.hrv, hrv: data.hrv,
oxygenSaturation: data.oxygenSaturation, oxygenSaturation: data.oxygenSaturation,
heartRate: data.heartRate, heartRate: data.heartRate,
@@ -588,8 +586,8 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}> <FloatingCard style={styles.masonryCard}>
<SleepCard <SleepCard
sleepDuration={sleepDuration} selectedDate={currentSelectedDate}
onPress={() => pushIfAuthedElseLogin('/sleep-detail')} onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
/> />
</FloatingCard> </FloatingCard>
</View> </View>

View File

@@ -9,7 +9,7 @@ import {
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@@ -27,113 +27,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
// 简化的睡眠阶段图表组件
const SleepStageChart = ({
sleepData,
onInfoPress
}: {
sleepData: SleepDetailData;
onInfoPress: () => void;
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 使用真实的睡眠阶段数据,如果没有则使用默认数据
const stages = sleepData.sleepStages.length > 0
? sleepData.sleepStages
.filter(stage => stage.percentage > 0) // 只显示有数据的阶段
.map(stage => ({
stage: stage.stage,
percentage: stage.percentage,
duration: stage.duration
}))
: [
{ stage: SleepStage.Awake, percentage: 1, duration: 3 },
{ stage: SleepStage.REM, percentage: 20, duration: 89 },
{ stage: SleepStage.Core, percentage: 67, duration: 295 },
{ stage: SleepStage.Deep, percentage: 12, duration: 51 }
];
return (
<View style={styles.simplifiedChartContainer}>
<View style={styles.chartTitleContainer}>
<Text style={styles.chartTitle}></Text>
<TouchableOpacity
style={styles.chartInfoButton}
onPress={onInfoPress}
>
<Ionicons name="help-circle-outline" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
{/* 入睡时间和起床时间显示 */}
<View style={styles.sleepTimeLabels}>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.bedtime ? formatTime(sleepData.bedtime) : '--:--'}
</Text>
</View>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '--:--'}
</Text>
</View>
</View>
{/* 简化的睡眠阶段条 */}
<View style={styles.simplifiedChartBar}>
{stages.map((stageData, index) => {
const color = getSleepStageColor(stageData.stage);
// 确保最小宽度,避免清醒阶段等小比例的阶段完全不可见
const flexValue = Math.max(stageData.percentage || 1, 3);
return (
<View
key={index}
style={[
styles.stageSegment,
{
backgroundColor: color,
flex: flexValue,
}
]}
/>
);
})}
</View>
{/* 图例 */}
<View style={styles.chartLegend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
</View>
);
};
// SleepGradeCard 组件现在在 InfoModal 组件内部 // SleepGradeCard 组件现在在 InfoModal 组件内部
// SleepStagesInfoModal 组件现在从独立文件导入 // SleepStagesInfoModal 组件现在从独立文件导入
@@ -145,7 +38,15 @@ export default function SleepDetailScreen() {
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null); const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedDate] = useState(dayjs().toDate());
// 从导航参数获取日期,如果没有则使用今天
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const [selectedDate] = useState(() => {
if (dateParam) {
return dayjs(dateParam).toDate();
}
return dayjs().toDate();
});
const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({ const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({
visible: false, visible: false,
@@ -220,7 +121,7 @@ export default function SleepDetailScreen() {
{/* 顶部导航 */} {/* 顶部导航 */}
<HeaderBar <HeaderBar
title={`今天, ${dayjs(selectedDate).format('M月DD日')}`} title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
onBack={() => router.back()} onBack={() => router.back()}
withSafeTop={true} withSafeTop={true}
transparent={true} transparent={true}

View File

@@ -1,30 +1,48 @@
import React from 'react'; import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface SleepCardProps { interface SleepCardProps {
sleepDuration?: number | null; selectedDate?: Date;
style?: object; style?: object;
onPress?: () => void; onPress?: () => void;
} }
const SleepCard: React.FC<SleepCardProps> = ({ const SleepCard: React.FC<SleepCardProps> = ({
sleepDuration, selectedDate,
style, style,
onPress onPress
}) => { }) => {
const formatSleepDuration = (duration: number): string => { const [sleepDuration, setSleepDuration] = useState<number | null>(null);
const hours = Math.floor(duration / 60); const [loading, setLoading] = useState(false);
const minutes = Math.floor(duration % 60);
return `${hours}小时${minutes}分钟`; // 获取睡眠数据
useEffect(() => {
const loadSleepData = async () => {
if (!selectedDate) return;
try {
setLoading(true);
const data = await fetchCompleteSleepData(selectedDate);
setSleepDuration(data?.totalSleepTime || null);
} catch (error) {
console.error('SleepCard: 获取睡眠数据失败:', error);
setSleepDuration(null);
} finally {
setLoading(false);
}
}; };
loadSleepData();
}, [selectedDate]);
const CardContent = ( const CardContent = (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
</View> </View>
<Text style={styles.sleepValue}> <Text style={styles.sleepValue}>
{sleepDuration != null ? formatSleepDuration(sleepDuration) : '——'} {loading ? '加载中...' : (sleepDuration != null ? formatSleepTime(sleepDuration) : '--')}
</Text> </Text>
</View> </View>
); );

View File

@@ -1,6 +1,6 @@
import { HourlyStepData } from '@/utils/health';
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 {
@@ -16,7 +16,6 @@ export interface HealthData {
steps: number | null; steps: number | null;
activeCalories: number | null; activeCalories: number | null;
basalEnergyBurned: number | null; basalEnergyBurned: number | null;
sleepDuration: number | null;
hrv: number | null; hrv: number | null;
oxygenSaturation: number | null; oxygenSaturation: number | null;
heartRate: number | null; heartRate: number | null;

View File

@@ -57,7 +57,6 @@ export type TodayHealthData = {
steps: number; steps: number;
activeEnergyBurned: number; // kilocalories activeEnergyBurned: number; // kilocalories
basalEnergyBurned: number; // kilocalories - 基础代谢率 basalEnergyBurned: number; // kilocalories - 基础代谢率
sleepDuration: number; // 睡眠时长(分钟)
hrv: number | null; // 心率变异性 (ms) hrv: number | null; // 心率变异性 (ms)
// 健身圆环数据 // 健身圆环数据
activeCalories: number; activeCalories: number;
@@ -442,43 +441,6 @@ async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise<numbe
}); });
} }
async function fetchSleepDuration(date: Date): Promise<number> {
return new Promise((resolve) => {
// 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
const sleepOptions = createSleepDateRange(date);
AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
if (err) {
logError('睡眠数据', err);
return resolve(0);
}
if (!res || !Array.isArray(res) || res.length === 0) {
logWarning('睡眠', '为空或格式错误');
return resolve(0);
}
logSuccess('睡眠', res);
// 过滤睡眠数据,只计算主睡眠时间段
const filteredSamples = res.filter(sample => {
if (!sample || !sample.startDate || !sample.endDate) return false;
const startDate = dayjs(sample.startDate);
const endDate = dayjs(sample.endDate);
const targetDate = dayjs(date);
// 判断这个睡眠段是否属于当天的主睡眠
// 睡眠段的结束时间应该在当天,或者睡眠段跨越了前一天晚上到当天早上
const isMainSleepPeriod = endDate.isSame(targetDate, 'day') ||
(startDate.isBefore(targetDate, 'day') && endDate.isAfter(targetDate.startOf('day')));
return isMainSleepPeriod;
});
resolve(calculateSleepDuration(filteredSamples));
});
});
}
async function fetchHeartRateVariability(options: HealthDataOptions): Promise<number | null> { async function fetchHeartRateVariability(options: HealthDataOptions): Promise<number | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
console.log('=== 开始获取HRV数据 ==='); console.log('=== 开始获取HRV数据 ===');
@@ -626,7 +588,6 @@ function getDefaultHealthData(): TodayHealthData {
steps: 0, steps: 0,
activeEnergyBurned: 0, activeEnergyBurned: 0,
basalEnergyBurned: 0, basalEnergyBurned: 0,
sleepDuration: 0,
hrv: null, hrv: null,
activeCalories: 0, activeCalories: 0,
activeCaloriesGoal: 350, activeCaloriesGoal: 350,
@@ -653,7 +614,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
hourlySteps, hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
sleepDuration,
hrv, hrv,
activitySummary, activitySummary,
oxygenSaturation, oxygenSaturation,
@@ -663,31 +623,17 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
fetchHourlyStepSamples(date), fetchHourlyStepSamples(date),
fetchActiveEnergyBurned(options), fetchActiveEnergyBurned(options),
fetchBasalEnergyBurned(options), fetchBasalEnergyBurned(options),
fetchSleepDuration(date), // 传入日期而不是options
fetchHeartRateVariability(options), fetchHeartRateVariability(options),
fetchActivitySummary(options), fetchActivitySummary(options),
fetchOxygenSaturation(options), fetchOxygenSaturation(options),
fetchHeartRate(options) fetchHeartRate(options)
]); ]);
console.log('指定日期健康数据获取完成:', {
steps,
hourlySteps,
activeEnergyBurned,
basalEnergyBurned,
sleepDuration,
hrv,
activitySummary,
oxygenSaturation,
heartRate
});
return { return {
steps, steps,
hourlySteps, hourlySteps,
activeEnergyBurned, activeEnergyBurned,
basalEnergyBurned, basalEnergyBurned,
sleepDuration,
hrv, hrv,
activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0), activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0),
activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350), activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350),