refactor(sleep): 重构睡眠数据获取逻辑,移除冗余代码并优化组件结构
- 从 healthSlice 和 health.ts 中移除 sleepDuration 字段及相关获取逻辑 - 将 SleepCard 改为按需异步获取睡眠数据,支持传入指定日期 - 睡眠详情页改为通过路由参数接收日期,支持查看历史记录 - 移除 statistics 页面对 sleepDuration 的直接依赖,统一由 SleepCard 管理 - 删除未使用的 SleepStageChart 组件,简化页面结构
This commit is contained in:
@@ -98,7 +98,6 @@ export default function ExploreScreen() {
|
||||
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 ?? null) : (healthData?.hrv ?? null);
|
||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||
|
||||
@@ -293,7 +292,6 @@ export default function ExploreScreen() {
|
||||
steps: data.steps,
|
||||
activeCalories: data.activeEnergyBurned,
|
||||
basalEnergyBurned: data.basalEnergyBurned,
|
||||
sleepDuration: data.sleepDuration,
|
||||
hrv: data.hrv,
|
||||
oxygenSaturation: data.oxygenSaturation,
|
||||
heartRate: data.heartRate,
|
||||
@@ -587,9 +585,9 @@ export default function ExploreScreen() {
|
||||
</FloatingCard> */}
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<SleepCard
|
||||
sleepDuration={sleepDuration}
|
||||
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
||||
<SleepCard
|
||||
selectedDate={currentSelectedDate}
|
||||
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
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 {
|
||||
ActivityIndicator,
|
||||
@@ -27,113 +27,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
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 组件内部
|
||||
|
||||
// SleepStagesInfoModal 组件现在从独立文件导入
|
||||
@@ -145,7 +38,15 @@ export default function SleepDetailScreen() {
|
||||
const colorTokens = Colors[theme];
|
||||
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
||||
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 }>({
|
||||
visible: false,
|
||||
@@ -220,7 +121,7 @@ export default function SleepDetailScreen() {
|
||||
|
||||
{/* 顶部导航 */}
|
||||
<HeaderBar
|
||||
title={`今天, ${dayjs(selectedDate).format('M月DD日')}`}
|
||||
title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={true}
|
||||
transparent={true}
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface SleepCardProps {
|
||||
sleepDuration?: number | null;
|
||||
selectedDate?: Date;
|
||||
style?: object;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const SleepCard: React.FC<SleepCardProps> = ({
|
||||
sleepDuration,
|
||||
selectedDate,
|
||||
style,
|
||||
onPress
|
||||
}) => {
|
||||
const formatSleepDuration = (duration: number): string => {
|
||||
const hours = Math.floor(duration / 60);
|
||||
const minutes = Math.floor(duration % 60);
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
};
|
||||
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取睡眠数据
|
||||
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 = (
|
||||
<View style={[styles.container, style]}>
|
||||
@@ -24,7 +42,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
<Text style={styles.sleepValue}>
|
||||
{sleepDuration != null ? formatSleepDuration(sleepDuration) : '——'}
|
||||
{loading ? '加载中...' : (sleepDuration != null ? formatSleepTime(sleepDuration) : '--')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HourlyStepData } from '@/utils/health';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { AppDispatch, RootState } from './index';
|
||||
import { HourlyStepData } from '@/utils/health';
|
||||
|
||||
// 健康数据类型定义
|
||||
export interface FitnessRingsData {
|
||||
@@ -16,7 +16,6 @@ export interface HealthData {
|
||||
steps: number | null;
|
||||
activeCalories: number | null;
|
||||
basalEnergyBurned: number | null;
|
||||
sleepDuration: number | null;
|
||||
hrv: number | null;
|
||||
oxygenSaturation: number | null;
|
||||
heartRate: number | null;
|
||||
|
||||
@@ -57,7 +57,6 @@ export type TodayHealthData = {
|
||||
steps: number;
|
||||
activeEnergyBurned: number; // kilocalories
|
||||
basalEnergyBurned: number; // kilocalories - 基础代谢率
|
||||
sleepDuration: number; // 睡眠时长(分钟)
|
||||
hrv: number | null; // 心率变异性 (ms)
|
||||
// 健身圆环数据
|
||||
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> {
|
||||
return new Promise((resolve) => {
|
||||
console.log('=== 开始获取HRV数据 ===');
|
||||
@@ -626,7 +588,6 @@ function getDefaultHealthData(): TodayHealthData {
|
||||
steps: 0,
|
||||
activeEnergyBurned: 0,
|
||||
basalEnergyBurned: 0,
|
||||
sleepDuration: 0,
|
||||
hrv: null,
|
||||
activeCalories: 0,
|
||||
activeCaloriesGoal: 350,
|
||||
@@ -653,7 +614,6 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
sleepDuration,
|
||||
hrv,
|
||||
activitySummary,
|
||||
oxygenSaturation,
|
||||
@@ -663,31 +623,17 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
fetchHourlyStepSamples(date),
|
||||
fetchActiveEnergyBurned(options),
|
||||
fetchBasalEnergyBurned(options),
|
||||
fetchSleepDuration(date), // 传入日期而不是options
|
||||
fetchHeartRateVariability(options),
|
||||
fetchActivitySummary(options),
|
||||
fetchOxygenSaturation(options),
|
||||
fetchHeartRate(options)
|
||||
]);
|
||||
|
||||
console.log('指定日期健康数据获取完成:', {
|
||||
steps,
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
sleepDuration,
|
||||
hrv,
|
||||
activitySummary,
|
||||
oxygenSaturation,
|
||||
heartRate
|
||||
});
|
||||
|
||||
return {
|
||||
steps,
|
||||
hourlySteps,
|
||||
activeEnergyBurned,
|
||||
basalEnergyBurned,
|
||||
sleepDuration,
|
||||
hrv,
|
||||
activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0),
|
||||
activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350),
|
||||
|
||||
Reference in New Issue
Block a user