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