diff --git a/app.json b/app.json
index 6b6d47d..3c2a98a 100644
--- a/app.json
+++ b/app.json
@@ -88,14 +88,6 @@
{
"minimumInterval": 15
}
- ],
- [
- "expo-task-manager",
- {
- "taskManagers": [
- "background-health-reminders"
- ]
- }
]
],
"experiments": {
diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx
index c7fed35..d050b08 100644
--- a/app/(tabs)/statistics.tsx
+++ b/app/(tabs)/statistics.tsx
@@ -4,6 +4,7 @@ import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
+import SleepCard from '@/components/statistic/SleepCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
@@ -12,6 +13,7 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
+import { backgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
@@ -21,7 +23,6 @@ import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } fro
import { getTestHealthData } from '@/utils/mockHealthData';
import { calculateNutritionGoals } from '@/utils/nutrition';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
-import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
@@ -64,7 +65,8 @@ export default function ExploreScreen() {
const userProfile = useAppSelector((s) => s.user.profile);
// 开发调试:设置为true来使用mock数据
- const useMockData = __DEV__; // 改为true来启用mock数据调试
+ // 在真机测试时,可以暂时设置为true来验证组件显示逻辑
+ const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
@@ -97,9 +99,18 @@ export default function ExploreScreen() {
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 hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
+ // 调试HRV数据
+ console.log('=== HRV数据调试 ===');
+ console.log('useMockData:', useMockData);
+ console.log('mockData?.hrv:', mockData?.hrv);
+ console.log('healthData?.hrv:', healthData?.hrv);
+ console.log('final hrvValue:', hrvValue);
+ console.log('healthData:', healthData);
+ console.log('==================');
+
const fitnessRingsData = useMockData ? {
activeCalories: mockData?.activeCalories ?? 0,
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
@@ -269,6 +280,8 @@ export default function ExploreScreen() {
const data = await fetchHealthDataForDate(derivedDate);
console.log('设置UI状态:', data);
+ console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
+
// 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) {
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
@@ -276,7 +289,22 @@ export default function ExploreScreen() {
// 使用 Redux 存储健康数据
dispatch(setHealthData({
date: dateString,
- data: data
+ data: {
+ steps: data.steps,
+ activeCalories: data.activeEnergyBurned,
+ basalEnergyBurned: data.basalEnergyBurned,
+ sleepDuration: data.sleepDuration,
+ hrv: data.hrv,
+ oxygenSaturation: data.oxygenSaturation,
+ heartRate: data.heartRate,
+ activeEnergyBurned: data.activeEnergyBurned,
+ activeCaloriesGoal: data.activeCaloriesGoal,
+ exerciseMinutes: data.exerciseMinutes,
+ exerciseMinutesGoal: data.exerciseMinutesGoal,
+ standHours: data.standHours,
+ standHoursGoal: data.standHoursGoal,
+ hourlySteps: data.hourlySteps,
+ }
}));
// 更新HRV数据时间
@@ -374,26 +402,33 @@ export default function ExploreScreen() {
}, [executeLoadAllData, debouncedLoadAllData]);
// 页面聚焦时的数据加载逻辑
- useFocusEffect(
- React.useCallback(() => {
- // 页面聚焦时加载数据,使用缓存机制避免频繁请求
- console.log('页面聚焦,检查是否需要刷新数据...');
- loadAllData(currentSelectedDate);
- }, [loadAllData, currentSelectedDate])
- );
+ // useFocusEffect(
+ // React.useCallback(() => {
+ // // 页面聚焦时加载数据,使用缓存机制避免频繁请求
+ // console.log('页面聚焦,检查是否需要刷新数据...');
+ // loadAllData(currentSelectedDate);
+ // }, [loadAllData, currentSelectedDate])
+ // );
// AppState 监听:应用从后台返回前台时的处理
useEffect(() => {
- let appStateChangeTimeout: number;
-
const handleAppStateChange = (nextAppState: string) => {
if (nextAppState === 'active') {
- // 延迟执行,避免与 useFocusEffect 重复触发
- appStateChangeTimeout = setTimeout(() => {
- console.log('应用从后台返回前台,强制刷新统计数据...');
- // 从后台返回时强制刷新数据
- loadAllData(currentSelectedDate, true);
- }, 500);
+ // 判断当前选中的日期是否是最新的(今天)
+ const todayIndex = getTodayIndexInMonth();
+ const isTodaySelected = selectedIndex === todayIndex;
+
+ if (!isTodaySelected) {
+ // 如果当前不是选中今天,则切换到今天(这个更新会触发数据加载)
+ console.log('应用回到前台,切换到今天并加载数据');
+ setSelectedIndex(todayIndex);
+ // 注意:这里不直接调用loadAllData,因为setSelectedIndex会触发useEffect重新计算currentSelectedDate
+ // 然后onSelectDate会被调用,从而触发数据加载
+ } else {
+ // 如果已经是今天,则直接调用加载数据的方法
+ console.log('应用回到前台,当前已是今天,直接加载数据');
+ loadAllData(currentSelectedDate);
+ }
}
};
@@ -401,11 +436,8 @@ export default function ExploreScreen() {
return () => {
subscription?.remove();
- if (appStateChangeTimeout) {
- clearTimeout(appStateChangeTimeout);
- }
};
- }, [loadAllData, currentSelectedDate]);
+ }, [loadAllData, currentSelectedDate, selectedIndex]);
// 日期点击时,加载对应日期数据
@@ -463,7 +495,7 @@ export default function ExploreScreen() {
style={styles.debugButton}
onPress={async () => {
console.log('🔧 手动触发后台任务测试...');
- // await backgroundTaskManager.triggerTaskForTesting();
+ await backgroundTaskManager.triggerTaskForTesting();
}}
>
🔧
@@ -555,16 +587,10 @@ export default function ExploreScreen() {
*/}
-
- 睡眠
-
- {sleepDuration != null ? (
-
- {Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟
-
- ) : (
- ——
- )}
+ pushIfAuthedElseLogin('/sleep-detail')}
+ />
@@ -960,12 +986,6 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
marginTop: 8,
},
- sleepValue: {
- fontSize: 16,
- color: '#1E40AF',
- fontWeight: '700',
- marginTop: 8,
- },
weightCard: {
backgroundColor: '#F0F9FF',
},
diff --git a/app/_layout.tsx b/app/_layout.tsx
index f5ae919..5f25658 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -73,7 +73,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
if (userDataLoaded && profile?.name) {
try {
await notificationService.initialize();
-
+ // 后台任务
+ await backgroundTaskManager.initialize()
// 注册午餐提醒(12:00)
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name);
console.log('午餐提醒已注册');
@@ -86,8 +87,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name);
console.log('心情提醒已注册');
- // 注册喝水提醒后台任务
- await backgroundTaskManager.registerWaterReminderTask();
+
console.log('喝水提醒后台任务已注册');
} catch (error) {
console.error('注册提醒失败:', error);
diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx
new file mode 100644
index 0000000..958aac8
--- /dev/null
+++ b/app/sleep-detail.tsx
@@ -0,0 +1,569 @@
+import React, { useEffect, useState } from 'react';
+import {
+ StyleSheet,
+ Text,
+ View,
+ ScrollView,
+ TouchableOpacity,
+ Dimensions,
+ ActivityIndicator,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { router } from 'expo-router';
+import dayjs from 'dayjs';
+import { LinearGradient } from 'expo-linear-gradient';
+import Svg, { Circle } from 'react-native-svg';
+
+import {
+ fetchSleepDetailForDate,
+ SleepDetailData,
+ SleepStage,
+ getSleepStageDisplayName,
+ getSleepStageColor,
+ formatSleepTime,
+ formatTime
+} from '@/services/sleepService';
+import { ensureHealthPermissions } from '@/utils/health';
+import { Colors } from '@/constants/Colors';
+
+const { width } = Dimensions.get('window');
+
+// 圆形进度条组件
+const CircularProgress = ({
+ size,
+ strokeWidth,
+ progress,
+ color,
+ backgroundColor = '#E5E7EB'
+}: {
+ size: number;
+ strokeWidth: number;
+ progress: number; // 0-100
+ color: string;
+ backgroundColor?: string;
+}) => {
+ const radius = (size - strokeWidth) / 2;
+ const circumference = radius * 2 * Math.PI;
+ const strokeDasharray = circumference;
+ const strokeDashoffset = circumference - (progress / 100) * circumference;
+
+ return (
+
+ );
+};
+
+// 睡眠阶段图表组件
+const SleepStageChart = ({ sleepData }: { sleepData: SleepDetailData }) => {
+ const chartWidth = width - 80;
+ const maxHeight = 120;
+
+ // 生成24小时的睡眠阶段数据(模拟数据,实际应根据真实样本计算)
+ const hourlyData = Array.from({ length: 24 }, (_, hour) => {
+ // 如果没有数据,显示空状态
+ if (sleepData.totalSleepTime === 0) {
+ return null;
+ }
+
+ // 根据时间判断可能的睡眠状态
+ if (hour >= 0 && hour <= 6) {
+ // 凌晨0-6点,主要睡眠时间
+ if (hour <= 2) return SleepStage.Core;
+ if (hour <= 4) return SleepStage.Deep;
+ return SleepStage.REM;
+ } else if (hour >= 22) {
+ // 晚上10点后开始入睡
+ return SleepStage.Core;
+ }
+ return null; // 清醒时间
+ });
+
+ return (
+
+
+
+ 🛏️ {sleepData.totalSleepTime > 0 ? formatTime(sleepData.bedtime) : '--:--'}
+
+
+ ❤️ 平均心率: {sleepData.averageHeartRate || '--'} BPM
+
+
+ ☀️ {sleepData.totalSleepTime > 0 ? formatTime(sleepData.wakeupTime) : '--:--'}
+
+
+
+
+ {hourlyData.map((stage, index) => {
+ const barHeight = stage ? Math.random() * 0.6 + 0.4 : 0.1; // 随机高度模拟真实数据
+ const color = stage ? getSleepStageColor(stage) : '#E5E7EB';
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
+
+export default function SleepDetailScreen() {
+ const insets = useSafeAreaInsets();
+ const [sleepData, setSleepData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [selectedDate] = useState(dayjs().toDate());
+
+ useEffect(() => {
+ loadSleepData();
+ }, [selectedDate]);
+
+ const loadSleepData = async () => {
+ try {
+ setLoading(true);
+
+ // 确保有健康权限
+ const hasPermission = await ensureHealthPermissions();
+ if (!hasPermission) {
+ console.warn('没有健康数据权限');
+ return;
+ }
+
+ // 获取睡眠详情数据
+ const data = await fetchSleepDetailForDate(selectedDate);
+ setSleepData(data);
+
+ } catch (error) {
+ console.error('加载睡眠数据失败:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+ 加载睡眠数据中...
+
+ );
+ }
+
+ // 如果没有数据,使用默认数据结构
+ const displayData: SleepDetailData = sleepData || {
+ sleepScore: 0,
+ totalSleepTime: 0,
+ sleepQualityPercentage: 0,
+ bedtime: new Date().toISOString(),
+ wakeupTime: new Date().toISOString(),
+ timeInBed: 0,
+ sleepStages: [],
+ averageHeartRate: null,
+ sleepHeartRateData: [],
+ sleepEfficiency: 0,
+ qualityDescription: '暂无睡眠数据',
+ recommendation: '请确保在真实iOS设备上运行并授权访问健康数据,或等待有睡眠数据后再查看。'
+ };
+
+ return (
+
+ {/* 背景渐变 */}
+
+
+ {/* 顶部导航 */}
+
+ router.back()}>
+ ‹
+
+ 今天, {dayjs(selectedDate).format('M月DD日')}
+
+ ›
+
+
+
+
+ {/* 睡眠得分圆形显示 */}
+
+
+
+
+ {displayData.sleepScore}
+ 睡眠得分
+
+
+
+
+ {/* 睡眠质量描述 */}
+ {displayData.qualityDescription}
+
+ {/* 建议文本 */}
+ {displayData.recommendation}
+
+ {/* 睡眠统计卡片 */}
+
+
+ 🌙
+ 睡眠时间
+ {displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '--'}
+
+ {displayData.totalSleepTime > 0 ? '良好' : '--'}
+
+
+
+
+ 💎
+ 睡眠质量
+ {displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '--'}
+
+ {displayData.sleepQualityPercentage > 0 ? '优秀' : '--'}
+
+
+
+
+ {/* 睡眠阶段图表 */}
+
+
+ {/* 睡眠阶段统计 */}
+
+ {displayData.sleepStages.length > 0 ? displayData.sleepStages.map((stage, index) => (
+
+
+
+ {getSleepStageDisplayName(stage.stage)}
+
+
+ {stage.percentage}%
+ {formatSleepTime(stage.duration)}
+
+ {stage.quality === 'excellent' ? '优秀' :
+ stage.quality === 'good' ? '良好' :
+ stage.quality === 'fair' ? '一般' : '偏低'}
+
+
+
+ )) : (
+
+ 暂无睡眠阶段数据
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#F8FAFC',
+ },
+ gradientBackground: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 20,
+ paddingBottom: 16,
+ backgroundColor: 'transparent',
+ },
+ backButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ backButtonText: {
+ fontSize: 24,
+ fontWeight: '300',
+ color: '#374151',
+ },
+ headerTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#111827',
+ },
+ navButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ navButtonText: {
+ fontSize: 24,
+ fontWeight: '300',
+ color: '#9CA3AF',
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: 20,
+ paddingBottom: 40,
+ },
+ scoreContainer: {
+ alignItems: 'center',
+ marginVertical: 20,
+ },
+ circularProgressContainer: {
+ position: 'relative',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scoreTextContainer: {
+ position: 'absolute',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scoreNumber: {
+ fontSize: 48,
+ fontWeight: '800',
+ color: '#1F2937',
+ lineHeight: 48,
+ },
+ scoreLabel: {
+ fontSize: 14,
+ color: '#6B7280',
+ marginTop: 4,
+ },
+ qualityDescription: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: '#1F2937',
+ textAlign: 'center',
+ marginBottom: 16,
+ lineHeight: 24,
+ },
+ recommendationText: {
+ fontSize: 14,
+ color: '#6B7280',
+ textAlign: 'center',
+ lineHeight: 20,
+ marginBottom: 32,
+ paddingHorizontal: 16,
+ },
+ statsContainer: {
+ flexDirection: 'row',
+ gap: 16,
+ marginBottom: 32,
+ },
+ statCard: {
+ flex: 1,
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ borderRadius: 16,
+ padding: 16,
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ statIcon: {
+ fontSize: 24,
+ marginBottom: 8,
+ },
+ statLabel: {
+ fontSize: 12,
+ color: '#6B7280',
+ marginBottom: 4,
+ },
+ statValue: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#1F2937',
+ marginBottom: 4,
+ },
+ statQuality: {
+ fontSize: 12,
+ color: '#10B981',
+ fontWeight: '500',
+ },
+ chartContainer: {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ borderRadius: 16,
+ padding: 16,
+ marginBottom: 24,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ chartHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 16,
+ },
+ chartTimeLabel: {
+ alignItems: 'center',
+ },
+ chartTimeText: {
+ fontSize: 12,
+ color: '#6B7280',
+ fontWeight: '500',
+ },
+ chartHeartRate: {
+ alignItems: 'center',
+ },
+ chartHeartRateText: {
+ fontSize: 12,
+ color: '#EF4444',
+ fontWeight: '600',
+ },
+ chartBars: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ height: 120,
+ gap: 2,
+ },
+ chartBar: {
+ borderRadius: 2,
+ minHeight: 8,
+ },
+ stagesContainer: {
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ borderRadius: 16,
+ padding: 16,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 8,
+ elevation: 3,
+ },
+ stageRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: '#F3F4F6',
+ },
+ stageInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ },
+ stageColorDot: {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ marginRight: 12,
+ },
+ stageName: {
+ fontSize: 14,
+ color: '#374151',
+ fontWeight: '500',
+ },
+ stageStats: {
+ alignItems: 'flex-end',
+ },
+ stagePercentage: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#1F2937',
+ },
+ stageDuration: {
+ fontSize: 12,
+ color: '#6B7280',
+ marginTop: 2,
+ },
+ stageQuality: {
+ fontSize: 11,
+ fontWeight: '600',
+ marginTop: 2,
+ },
+ loadingContainer: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loadingText: {
+ fontSize: 16,
+ color: '#6B7280',
+ marginTop: 16,
+ },
+ errorText: {
+ fontSize: 16,
+ color: '#6B7280',
+ marginBottom: 16,
+ },
+ retryButton: {
+ backgroundColor: Colors.light.primary,
+ borderRadius: 8,
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ },
+ retryButtonText: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ noDataContainer: {
+ alignItems: 'center',
+ paddingVertical: 24,
+ },
+ noDataText: {
+ fontSize: 14,
+ color: '#9CA3AF',
+ fontStyle: 'italic',
+ },
+});
\ No newline at end of file
diff --git a/components/StressMeter.tsx b/components/StressMeter.tsx
index e7c5b69..86b01b4 100644
--- a/components/StressMeter.tsx
+++ b/components/StressMeter.tsx
@@ -8,7 +8,7 @@ interface StressMeterProps {
value: number | null;
updateTime?: Date;
style?: any;
- hrvValue: number;
+ hrvValue: number | null;
}
export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterProps) {
@@ -50,6 +50,13 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
// 使用传入的 hrvValue 进行转换
const stressIndex = convertHrvToStressIndex(hrvValue);
+ // 调试信息
+ console.log('StressMeter 调试:', {
+ hrvValue,
+ stressIndex,
+ progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
+ });
+
// 计算进度条位置(0-100%)
// 压力指数越高,进度条越满(红色区域越多)
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;
diff --git a/components/statistic/SleepCard.tsx b/components/statistic/SleepCard.tsx
new file mode 100644
index 0000000..336c91d
--- /dev/null
+++ b/components/statistic/SleepCard.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
+
+interface SleepCardProps {
+ sleepDuration?: number | null;
+ style?: object;
+ onPress?: () => void;
+}
+
+const SleepCard: React.FC = ({
+ sleepDuration,
+ style,
+ onPress
+}) => {
+ const formatSleepDuration = (duration: number): string => {
+ const hours = Math.floor(duration / 60);
+ const minutes = Math.floor(duration % 60);
+ return `${hours}小时${minutes}分钟`;
+ };
+
+ const CardContent = (
+
+
+ 睡眠
+
+
+ {sleepDuration != null ? formatSleepDuration(sleepDuration) : '——'}
+
+
+ );
+
+ if (onPress) {
+ return (
+
+ {CardContent}
+
+ );
+ }
+
+ return CardContent;
+};
+
+const styles = StyleSheet.create({
+ container: {
+ // Container styles will be inherited from parent (FloatingCard)
+ },
+ cardHeaderRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ cardTitle: {
+ fontSize: 14,
+ color: '#192126',
+ },
+ sleepValue: {
+ fontSize: 16,
+ color: '#1E40AF',
+ fontWeight: '700',
+ marginTop: 8,
+ },
+});
+
+export default SleepCard;
\ No newline at end of file
diff --git a/constants/Routes.ts b/constants/Routes.ts
index a9baa5d..eeeb3fa 100644
--- a/constants/Routes.ts
+++ b/constants/Routes.ts
@@ -43,6 +43,7 @@ export const ROUTES = {
// 健康相关路由
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
+ SLEEP_DETAIL: '/sleep-detail',
// 任务相关路由
TASK_DETAIL: '/task-detail',
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index b39d3f1..cb2afde 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1948,7 +1948,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - RNSentry (6.21.0):
+ - RNSentry (7.0.1):
- DoubleConversion
- glog
- RCT-Folly (= 2024.11.18.00)
@@ -2478,7 +2478,7 @@ SPEC CHECKSUMS:
RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8
- RNSentry: 605b0108f57a8b921ca5ef7aa0b97d469a723c57
+ RNSentry: 5e404b7714164b2d7b61a5ae41d7e9fa103b308c
RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46
SDWebImage: f29024626962457f3470184232766516dee8dfea
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
diff --git a/ios/digitalpilates/Info.plist b/ios/digitalpilates/Info.plist
index 086a0a8..c2263cc 100644
--- a/ios/digitalpilates/Info.plist
+++ b/ios/digitalpilates/Info.plist
@@ -4,7 +4,7 @@
BGTaskSchedulerPermittedIdentifiers
- background-health-reminders
+ com.expo.modules.backgroundtask.processing
CADisableMinimumFrameDurationOnPhone
diff --git a/package-lock.json b/package-lock.json
index a665ce6..bf32659 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6",
"@reduxjs/toolkit": "^2.8.2",
- "@sentry/react-native": "^6.20.0",
+ "@sentry/react-native": "^7.0.1",
"@types/lodash": "^4.17.20",
"cos-js-sdk-v5": "^1.6.0",
"dayjs": "^1.11.13",
@@ -3443,78 +3443,78 @@
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
- "integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz",
+ "integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==",
"license": "MIT",
"dependencies": {
- "@sentry/core": "8.55.0"
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
- "integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-10.8.0.tgz",
+ "integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==",
"license": "MIT",
"dependencies": {
- "@sentry/core": "8.55.0"
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-8.55.0.tgz",
- "integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-10.8.0.tgz",
+ "integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==",
"license": "MIT",
"dependencies": {
- "@sentry-internal/browser-utils": "8.55.0",
- "@sentry/core": "8.55.0"
+ "@sentry-internal/browser-utils": "10.8.0",
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
- "integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz",
+ "integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==",
"license": "MIT",
"dependencies": {
- "@sentry-internal/replay": "8.55.0",
- "@sentry/core": "8.55.0"
+ "@sentry-internal/replay": "10.8.0",
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
- "version": "4.2.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz",
- "integrity": "sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ==",
+ "version": "4.3.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz",
+ "integrity": "sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/@sentry/browser": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-8.55.0.tgz",
- "integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-10.8.0.tgz",
+ "integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==",
"license": "MIT",
"dependencies": {
- "@sentry-internal/browser-utils": "8.55.0",
- "@sentry-internal/feedback": "8.55.0",
- "@sentry-internal/replay": "8.55.0",
- "@sentry-internal/replay-canvas": "8.55.0",
- "@sentry/core": "8.55.0"
+ "@sentry-internal/browser-utils": "10.8.0",
+ "@sentry-internal/feedback": "10.8.0",
+ "@sentry-internal/replay": "10.8.0",
+ "@sentry-internal/replay-canvas": "10.8.0",
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry/cli": {
@@ -3706,44 +3706,43 @@
}
},
"node_modules/@sentry/core": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-8.55.0.tgz",
- "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-10.8.0.tgz",
+ "integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==",
"license": "MIT",
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sentry/react": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-8.55.0.tgz",
- "integrity": "sha512-/qNBvFLpvSa/Rmia0jpKfJdy16d4YZaAnH/TuKLAtm0BWlsPQzbXCU4h8C5Hsst0Do0zG613MEtEmWpWrVOqWA==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-10.8.0.tgz",
+ "integrity": "sha512-w/dGLMCLJG2lp8gKVKX1jjeg2inXewKfPb73+PS1CDi9/ihvqZU2DAXxnaNsBA7YYtGwlWVJe1bLAqguwTEpqw==",
"license": "MIT",
"dependencies": {
- "@sentry/browser": "8.55.0",
- "@sentry/core": "8.55.0",
+ "@sentry/browser": "10.8.0",
+ "@sentry/core": "10.8.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
- "node": ">=14.18"
+ "node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/react-native": {
- "version": "6.21.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-6.21.0.tgz",
- "integrity": "sha512-r8kroioyJCwDtfAgyPGRSLzfNIjNBF0d28+ZHkm0q9fbvcuBlXN3wtDBR+J+0JEbcZrFpYm2QtZWws/2TzP3NQ==",
+ "version": "7.0.1",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-7.0.1.tgz",
+ "integrity": "sha512-xz8ON51qSDvcHVFkdLo0b7rlrQVXpRVXqzm7e1+nHEZ07TX0o+utxx04akxD1Z4hmGPTWPmsHeMlm7diV9NtTQ==",
"license": "MIT",
"dependencies": {
- "@sentry/babel-plugin-component-annotate": "4.2.0",
- "@sentry/browser": "8.55.0",
+ "@sentry/babel-plugin-component-annotate": "4.3.0",
+ "@sentry/browser": "10.8.0",
"@sentry/cli": "2.53.0",
- "@sentry/core": "8.55.0",
- "@sentry/react": "8.55.0",
- "@sentry/types": "8.55.0",
- "@sentry/utils": "8.55.0"
+ "@sentry/core": "10.8.0",
+ "@sentry/react": "10.8.0",
+ "@sentry/types": "10.8.0"
},
"bin": {
"sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
@@ -3760,27 +3759,15 @@
}
},
"node_modules/@sentry/types": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-8.55.0.tgz",
- "integrity": "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog==",
+ "version": "10.8.0",
+ "resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-10.8.0.tgz",
+ "integrity": "sha512-xRe41/KvnNt4o6t5YeB+yBRTWvLUu6FJpft/VBOs4Bfh1/6rz+l78oxSCtpXo3MsfTd5185I0uuggAjEdD4Y6g==",
"license": "MIT",
"dependencies": {
- "@sentry/core": "8.55.0"
+ "@sentry/core": "10.8.0"
},
"engines": {
- "node": ">=14.18"
- }
- },
- "node_modules/@sentry/utils": {
- "version": "8.55.0",
- "resolved": "https://mirrors.tencent.com/npm/@sentry/utils/-/utils-8.55.0.tgz",
- "integrity": "sha512-cYcl39+xcOivBpN9d8ZKbALl+DxZKo/8H0nueJZ0PO4JA+MJGhSm6oHakXxLPaiMoNLTX7yor8ndnQIuFg+vmQ==",
- "license": "MIT",
- "dependencies": {
- "@sentry/core": "8.55.0"
- },
- "engines": {
- "node": ">=14.18"
+ "node": ">=18"
}
},
"node_modules/@sinclair/typebox": {
diff --git a/package.json b/package.json
index 7eaaa9c..0647ddc 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6",
"@reduxjs/toolkit": "^2.8.2",
- "@sentry/react-native": "^6.20.0",
+ "@sentry/react-native": "^7.0.1",
"@types/lodash": "^4.17.20",
"cos-js-sdk-v5": "^1.6.0",
"dayjs": "^1.11.13",
@@ -80,4 +80,4 @@
"typescript": "~5.8.3"
},
"private": true
-}
+}
\ No newline at end of file
diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts
index 22b850f..a12e7d7 100644
--- a/services/backgroundTaskManager.ts
+++ b/services/backgroundTaskManager.ts
@@ -5,17 +5,11 @@ import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import { TaskManagerTaskBody } from 'expo-task-manager';
-/**
- * 后台任务标识符
- */
-export const BACKGROUND_TASK_IDS = {
- WATER_REMINDER: 'water-reminder-task',
- STAND_REMINDER: 'stand-reminder-task',
- HEALTH_REMINDERS: 'background-health-reminders',
-} as const;
+const BACKGROUND_TASK_IDENTIFIER = 'background-task';
+
// 定义后台任务
-TaskManager.defineTask(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, async (body: TaskManagerTaskBody) => {
+TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
try {
console.log('[BackgroundTask] 后台任务执行');
await executeBackgroundTasks();
@@ -209,9 +203,7 @@ export class BackgroundTaskManager {
try {
// 注册后台任务
- const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, {
- minimumInterval: 15, // 15分钟
- });
+ const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
console.log('[BackgroundTask] 配置状态:', status);
@@ -226,26 +218,13 @@ export class BackgroundTaskManager {
- /**
- * 启动后台任务
- */
- async start(): Promise {
- try {
- await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, {
- minimumInterval: 15,
- });
- console.log('后台任务已启动');
- } catch (error) {
- console.error('启动后台任务失败:', error);
- }
- }
/**
* 停止后台任务
*/
async stop(): Promise {
try {
- await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS);
+ await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDENTIFIER);
console.log('后台任务已停止');
} catch (error) {
console.error('停止后台任务失败:', error);
@@ -281,6 +260,11 @@ export class BackgroundTaskManager {
}
}
+ async triggerTaskForTesting(): Promise {
+ await BackgroundTask.triggerTaskWorkerForTestingAsync();
+ }
+
+
/**
* 测试后台任务
*/
@@ -296,42 +280,6 @@ export class BackgroundTaskManager {
}
}
- /**
- * 注册喝水提醒后台任务
- */
- async registerWaterReminderTask(): Promise {
- console.log('注册喝水提醒后台任务...');
-
- try {
- // 检查是否已经初始化
- if (!this.isInitialized) {
- await this.initialize();
- }
-
- // 启动后台任务
- await this.start();
-
- console.log('喝水提醒后台任务注册成功');
- } catch (error) {
- console.error('注册喝水提醒后台任务失败:', error);
- throw error;
- }
- }
-
- /**
- * 取消喝水提醒后台任务
- */
- async unregisterWaterReminderTask(): Promise {
- console.log('取消喝水提醒后台任务...');
-
- try {
- await this.stop();
- console.log('喝水提醒后台任务已取消');
- } catch (error) {
- console.error('取消喝水提醒后台任务失败:', error);
- throw error;
- }
- }
/**
* 获取最后一次后台检查时间
@@ -345,73 +293,6 @@ export class BackgroundTaskManager {
return null;
}
}
-
- /**
- * 注册站立提醒后台任务
- */
- async registerStandReminderTask(): Promise {
- console.log('注册站立提醒后台任务...');
-
- try {
- // 检查是否已经初始化
- if (!this.isInitialized) {
- await this.initialize();
- }
-
- // 启动后台任务
- await this.start();
-
- console.log('站立提醒后台任务注册成功');
- } catch (error) {
- console.error('注册站立提醒后台任务失败:', error);
- throw error;
- }
- }
-
- /**
- * 取消站立提醒后台任务
- */
- async unregisterStandReminderTask(): Promise {
- console.log('取消站立提醒后台任务...');
-
- try {
- // 取消所有相关通知
- await StandReminderHelpers.cancelStandReminders();
- console.log('站立提醒后台任务已取消');
- } catch (error) {
- console.error('取消站立提醒后台任务失败:', error);
- throw error;
- }
- }
-
- /**
- * 获取最后一次站立检查时间
- */
- async getLastStandCheckTime(): Promise {
- try {
- const lastCheck = await AsyncStorage.getItem('@last_background_stand_check');
- return lastCheck ? parseInt(lastCheck) : null;
- } catch (error) {
- console.error('获取最后站立检查时间失败:', error);
- return null;
- }
- }
-
- /**
- * 测试站立提醒任务
- */
- async testStandReminderTask(): Promise {
- console.log('开始测试站立提醒后台任务...');
-
- try {
- // 手动触发站立提醒任务执行
- await executeStandReminderTask();
- console.log('站立提醒后台任务测试完成');
- } catch (error) {
- console.error('站立提醒后台任务测试失败:', error);
- }
- }
-
}
/**
diff --git a/services/sleepService.ts b/services/sleepService.ts
new file mode 100644
index 0000000..eab34f9
--- /dev/null
+++ b/services/sleepService.ts
@@ -0,0 +1,376 @@
+import dayjs from 'dayjs';
+import AppleHealthKit, { HealthKitPermissions } from 'react-native-health';
+
+// 睡眠阶段枚举(与 HealthKit 保持一致)
+export enum SleepStage {
+ InBed = 'INBED',
+ Asleep = 'ASLEEP',
+ Awake = 'AWAKE',
+ Core = 'CORE',
+ Deep = 'DEEP',
+ REM = 'REM'
+}
+
+// 睡眠质量评级
+export enum SleepQuality {
+ Poor = 'poor', // 差
+ Fair = 'fair', // 一般
+ Good = 'good', // 良好
+ Excellent = 'excellent' // 优秀
+}
+
+// 睡眠样本数据类型
+export type SleepSample = {
+ startDate: string;
+ endDate: string;
+ value: SleepStage;
+ sourceName?: string;
+ sourceId?: string;
+};
+
+// 睡眠阶段统计
+export type SleepStageStats = {
+ stage: SleepStage;
+ duration: number; // 分钟
+ percentage: number; // 百分比
+ quality: SleepQuality;
+};
+
+// 心率数据类型
+export type HeartRateData = {
+ timestamp: string;
+ value: number; // BPM
+};
+
+// 睡眠详情数据类型
+export type SleepDetailData = {
+ // 基础睡眠信息
+ sleepScore: number; // 睡眠得分 0-100
+ totalSleepTime: number; // 总睡眠时间(分钟)
+ sleepQualityPercentage: number; // 睡眠质量百分比
+
+ // 睡眠时间信息
+ bedtime: string; // 上床时间
+ wakeupTime: string; // 起床时间
+ timeInBed: number; // 在床时间(分钟)
+
+ // 睡眠阶段统计
+ sleepStages: SleepStageStats[];
+
+ // 心率数据
+ averageHeartRate: number | null; // 平均心率
+ sleepHeartRateData: HeartRateData[]; // 睡眠期间心率数据
+
+ // 睡眠效率
+ sleepEfficiency: number; // 睡眠效率百分比 (总睡眠时间/在床时间)
+
+ // 建议和评价
+ qualityDescription: string; // 睡眠质量描述
+ recommendation: string; // 睡眠建议
+};
+
+// 日期范围工具函数
+function createSleepDateRange(date: Date): { startDate: string; endDate: string } {
+ // 睡眠数据通常跨越两天,从前一天18:00到当天12:00
+ return {
+ startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toISOString(),
+ endDate: dayjs(date).hour(12).minute(0).second(0).millisecond(0).toISOString()
+ };
+}
+
+// 获取睡眠样本数据
+async function fetchSleepSamples(date: Date): Promise {
+ return new Promise((resolve) => {
+ const options = createSleepDateRange(date);
+
+ AppleHealthKit.getSleepSamples(options, (err, results) => {
+ if (err) {
+ console.error('获取睡眠样本失败:', err);
+ resolve([]);
+ return;
+ }
+
+ if (!results || !Array.isArray(results)) {
+ console.warn('睡眠样本数据为空');
+ resolve([]);
+ return;
+ }
+
+ console.log('获取到睡眠样本:', results.length);
+ resolve(results as SleepSample[]);
+ });
+ });
+}
+
+// 获取睡眠期间心率数据
+async function fetchSleepHeartRateData(bedtime: string, wakeupTime: string): Promise {
+ return new Promise((resolve) => {
+ const options = {
+ startDate: bedtime,
+ endDate: wakeupTime,
+ ascending: true
+ };
+
+ AppleHealthKit.getHeartRateSamples(options, (err, results) => {
+ if (err) {
+ console.error('获取睡眠心率数据失败:', err);
+ resolve([]);
+ return;
+ }
+
+ if (!results || !Array.isArray(results)) {
+ resolve([]);
+ return;
+ }
+
+ const heartRateData: HeartRateData[] = results.map(sample => ({
+ timestamp: sample.startDate,
+ value: Math.round(sample.value)
+ }));
+
+ console.log('获取到睡眠心率数据:', heartRateData.length, '个样本');
+ resolve(heartRateData);
+ });
+ });
+}
+
+// 计算睡眠阶段统计
+function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] {
+ const stageMap = new Map();
+
+ // 计算每个阶段的总时长
+ samples.forEach(sample => {
+ const startTime = dayjs(sample.startDate);
+ const endTime = dayjs(sample.endDate);
+ const duration = endTime.diff(startTime, 'minute');
+
+ const currentDuration = stageMap.get(sample.value) || 0;
+ stageMap.set(sample.value, currentDuration + duration);
+ });
+
+ // 计算总睡眠时间(排除在床时间)
+ const totalSleepTime = Array.from(stageMap.entries())
+ .filter(([stage]) => stage !== SleepStage.InBed && stage !== SleepStage.Awake)
+ .reduce((total, [, duration]) => total + duration, 0);
+
+ // 生成统计数据
+ const stats: SleepStageStats[] = [];
+
+ stageMap.forEach((duration, stage) => {
+ if (stage === SleepStage.InBed || stage === SleepStage.Awake) return;
+
+ const percentage = totalSleepTime > 0 ? (duration / totalSleepTime) * 100 : 0;
+ let quality: SleepQuality;
+
+ // 根据睡眠阶段和比例判断质量
+ switch (stage) {
+ case SleepStage.Deep:
+ quality = percentage >= 15 ? SleepQuality.Excellent :
+ percentage >= 10 ? SleepQuality.Good :
+ percentage >= 5 ? SleepQuality.Fair : SleepQuality.Poor;
+ break;
+ case SleepStage.REM:
+ quality = percentage >= 20 ? SleepQuality.Excellent :
+ percentage >= 15 ? SleepQuality.Good :
+ percentage >= 10 ? SleepQuality.Fair : SleepQuality.Poor;
+ break;
+ case SleepStage.Core:
+ quality = percentage >= 45 ? SleepQuality.Excellent :
+ percentage >= 35 ? SleepQuality.Good :
+ percentage >= 25 ? SleepQuality.Fair : SleepQuality.Poor;
+ break;
+ default:
+ quality = SleepQuality.Fair;
+ }
+
+ stats.push({
+ stage,
+ duration,
+ percentage: Math.round(percentage),
+ quality
+ });
+ });
+
+ // 按持续时间排序
+ return stats.sort((a, b) => b.duration - a.duration);
+}
+
+// 计算睡眠得分
+function calculateSleepScore(sleepStages: SleepStageStats[], sleepEfficiency: number, totalSleepTime: number): number {
+ let score = 0;
+
+ // 睡眠时长得分 (30分)
+ const idealSleepHours = 8 * 60; // 8小时
+ const sleepDurationScore = Math.min(30, (totalSleepTime / idealSleepHours) * 30);
+ score += sleepDurationScore;
+
+ // 睡眠效率得分 (25分)
+ const efficiencyScore = (sleepEfficiency / 100) * 25;
+ score += efficiencyScore;
+
+ // 深度睡眠得分 (25分)
+ const deepSleepStage = sleepStages.find(stage => stage.stage === SleepStage.Deep);
+ const deepSleepScore = deepSleepStage ? Math.min(25, (deepSleepStage.percentage / 20) * 25) : 0;
+ score += deepSleepScore;
+
+ // REM睡眠得分 (20分)
+ const remSleepStage = sleepStages.find(stage => stage.stage === SleepStage.REM);
+ const remSleepScore = remSleepStage ? Math.min(20, (remSleepStage.percentage / 25) * 20) : 0;
+ score += remSleepScore;
+
+ return Math.round(Math.min(100, score));
+}
+
+// 获取睡眠质量描述和建议
+function getSleepQualityInfo(sleepScore: number): { description: string; recommendation: string } {
+ if (sleepScore >= 85) {
+ return {
+ description: '你身心愉悦并且精力充沛',
+ recommendation: '恭喜你获得优质的睡眠!如果你感到精力充沛,可以考虑中等强度的运动,以维持健康的生活方式,并进一步减轻压力,以获得最佳睡眠。'
+ };
+ } else if (sleepScore >= 70) {
+ return {
+ description: '睡眠质量良好,精神状态不错',
+ recommendation: '你的睡眠质量还不错,但还有改善空间。建议保持规律的睡眠时间,睡前避免使用电子设备,营造安静舒适的睡眠环境。'
+ };
+ } else if (sleepScore >= 50) {
+ return {
+ description: '睡眠质量一般,可能影响日间表现',
+ recommendation: '你的睡眠需要改善。建议制定固定的睡前例行程序,限制咖啡因摄入,确保卧室温度适宜,考虑进行轻度运动来改善睡眠质量。'
+ };
+ } else {
+ return {
+ description: '睡眠质量较差,建议重视睡眠健康',
+ recommendation: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
+ };
+ }
+}
+
+// 获取睡眠阶段中文名称
+export function getSleepStageDisplayName(stage: SleepStage): string {
+ switch (stage) {
+ case SleepStage.Deep:
+ return '深度';
+ case SleepStage.Core:
+ return '核心';
+ case SleepStage.REM:
+ return '快速眼动';
+ case SleepStage.Asleep:
+ return '浅睡';
+ case SleepStage.Awake:
+ return '清醒';
+ case SleepStage.InBed:
+ return '在床';
+ default:
+ return '未知';
+ }
+}
+
+// 获取睡眠质量颜色
+export function getSleepStageColor(stage: SleepStage): string {
+ switch (stage) {
+ case SleepStage.Deep:
+ return '#1E40AF'; // 深蓝色
+ case SleepStage.Core:
+ return '#3B82F6'; // 蓝色
+ case SleepStage.REM:
+ return '#8B5CF6'; // 紫色
+ case SleepStage.Asleep:
+ return '#06B6D4'; // 青色
+ case SleepStage.Awake:
+ return '#F59E0B'; // 橙色
+ case SleepStage.InBed:
+ return '#6B7280'; // 灰色
+ default:
+ return '#9CA3AF';
+ }
+}
+
+// 主函数:获取完整的睡眠详情数据
+export async function fetchSleepDetailForDate(date: Date): Promise {
+ try {
+ console.log('开始获取睡眠详情数据...', date);
+
+ // 获取睡眠样本数据
+ const sleepSamples = await fetchSleepSamples(date);
+
+ if (sleepSamples.length === 0) {
+ console.warn('没有找到睡眠数据');
+ return null;
+ }
+
+ // 找到上床时间和起床时间
+ const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
+ const bedtime = inBedSamples.length > 0 ? inBedSamples[0].startDate : sleepSamples[0].startDate;
+ const wakeupTime = inBedSamples.length > 0 ?
+ inBedSamples[inBedSamples.length - 1].endDate :
+ sleepSamples[sleepSamples.length - 1].endDate;
+
+ // 计算在床时间
+ const timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
+
+ // 计算睡眠阶段统计
+ const sleepStages = calculateSleepStageStats(sleepSamples);
+
+ // 计算总睡眠时间
+ const totalSleepTime = sleepStages.reduce((total, stage) => total + stage.duration, 0);
+
+ // 计算睡眠效率
+ const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;
+
+ // 获取睡眠期间心率数据
+ const sleepHeartRateData = await fetchSleepHeartRateData(bedtime, wakeupTime);
+
+ // 计算平均心率
+ const averageHeartRate = sleepHeartRateData.length > 0 ?
+ Math.round(sleepHeartRateData.reduce((sum, data) => sum + data.value, 0) / sleepHeartRateData.length) :
+ null;
+
+ // 计算睡眠得分
+ const sleepScore = calculateSleepScore(sleepStages, sleepEfficiency, totalSleepTime);
+
+ // 获取质量描述和建议
+ const qualityInfo = getSleepQualityInfo(sleepScore);
+
+ const sleepDetailData: SleepDetailData = {
+ sleepScore,
+ totalSleepTime,
+ sleepQualityPercentage: sleepScore, // 使用睡眠得分作为质量百分比
+ bedtime,
+ wakeupTime,
+ timeInBed,
+ sleepStages,
+ averageHeartRate,
+ sleepHeartRateData,
+ sleepEfficiency,
+ qualityDescription: qualityInfo.description,
+ recommendation: qualityInfo.recommendation
+ };
+
+ console.log('睡眠详情数据获取完成:', sleepDetailData);
+ return sleepDetailData;
+
+ } catch (error) {
+ console.error('获取睡眠详情数据失败:', error);
+ return null;
+ }
+}
+
+// 格式化睡眠时间显示
+export function formatSleepTime(minutes: number): string {
+ const hours = Math.floor(minutes / 60);
+ const mins = minutes % 60;
+
+ if (hours > 0 && mins > 0) {
+ return `${hours}h ${mins}m`;
+ } else if (hours > 0) {
+ return `${hours}h`;
+ } else {
+ return `${mins}m`;
+ }
+}
+
+// 格式化时间显示 (HH:MM)
+export function formatTime(dateString: string): string {
+ return dayjs(dateString).format('HH:mm');
+}
\ No newline at end of file
diff --git a/utils/health.ts b/utils/health.ts
index c96881c..4b7eec1 100644
--- a/utils/health.ts
+++ b/utils/health.ts
@@ -103,6 +103,14 @@ function createDateRange(date: Date): HealthDataOptions {
};
}
+// 睡眠数据专用的日期范围函数 - 从前一天晚上到当天结束
+function createSleepDateRange(date: Date): HealthDataOptions {
+ return {
+ startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toDate().toISOString(), // 前一天18:00开始
+ endDate: dayjs(date).endOf('day').toDate().toISOString() // 当天结束
+ };
+}
+
// 睡眠时长计算
function calculateSleepDuration(samples: any[]): number {
return samples.reduce((total, sample) => {
@@ -433,9 +441,12 @@ async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise {
+async function fetchSleepDuration(date: Date): Promise {
return new Promise((resolve) => {
- AppleHealthKit.getSleepSamples(options, (err, res) => {
+ // 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
+ const sleepOptions = createSleepDateRange(date);
+
+ AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
if (err) {
logError('睡眠数据', err);
return resolve(0);
@@ -445,7 +456,24 @@ async function fetchSleepDuration(options: HealthDataOptions): Promise {
return resolve(0);
}
logSuccess('睡眠', res);
- resolve(calculateSleepDuration(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));
});
});
}
@@ -634,7 +662,7 @@ export async function fetchHealthDataForDate(date: Date): Promise