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 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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
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]}>
|
||||||
@@ -24,7 +42,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
|||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user