diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index ec9d66f..96b6237 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -221,9 +221,7 @@ export default function PersonalScreen() { : pushIfAuthedElseLogin('/profile/edit')}> - 编辑 + {isLoggedIn ? '编辑' : '登录'} } diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 544169b..413a464 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -95,28 +95,6 @@ export default function ExploreScreen() { const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null); - const fitnessRingsData = useMockData ? { - activeCalories: mockData?.activeCalories ?? 0, - activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350, - exerciseMinutes: mockData?.exerciseMinutes ?? 0, - exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30, - standHours: mockData?.standHours ?? 0, - standHoursGoal: mockData?.standHoursGoal ?? 12, - } : (healthData ? { - activeCalories: healthData.activeEnergyBurned, - activeCaloriesGoal: healthData.activeCaloriesGoal, - exerciseMinutes: healthData.exerciseMinutes, - exerciseMinutesGoal: healthData.exerciseMinutesGoal, - standHours: healthData.standHours, - standHoursGoal: healthData.standHoursGoal, - } : { - activeCalories: 0, - activeCaloriesGoal: 350, - exerciseMinutes: 0, - exerciseMinutesGoal: 30, - standHours: 0, - standHoursGoal: 12, - }); // 用于触发动画重置的 token(当日期或数据变化时更新) const [animToken, setAnimToken] = useState(0); @@ -564,7 +542,6 @@ export default function ExploreScreen() { pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)} /> @@ -573,12 +550,7 @@ export default function ExploreScreen() { diff --git a/app/_layout.tsx b/app/_layout.tsx index 09bd40f..0456e8c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,7 +16,7 @@ import { WaterRecordSource } from '@/services/waterRecords'; import { store } from '@/store'; import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice'; import { createWaterRecordAction } from '@/store/waterSlice'; -import { ensureHealthPermissions } from '@/utils/health'; +import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health'; import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React from 'react'; @@ -44,13 +44,24 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { }; const initHealthPermissions = async () => { - // 初始化 HealthKit 权限 + // 初始化 HealthKit 权限管理系统 try { - console.log('开始请求 HealthKit 权限...'); - await ensureHealthPermissions(); - console.log('HealthKit 权限初始化完成'); + console.log('初始化 HealthKit 权限管理系统...'); + initializeHealthPermissions(); + + // 延迟请求权限,避免应用启动时弹窗 + setTimeout(async () => { + try { + await ensureHealthPermissions(); + console.log('HealthKit 权限请求完成'); + } catch (error) { + console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error); + } + }, 2000); + + console.log('HealthKit 权限管理初始化完成'); } catch (error) { - console.warn('HealthKit 权限初始化失败,可能在模拟器上运行:', error); + console.warn('HealthKit 权限管理初始化失败:', error); } } diff --git a/components/FitnessRingsCard.tsx b/components/FitnessRingsCard.tsx index 36a2275..3c4bc05 100644 --- a/components/FitnessRingsCard.tsx +++ b/components/FitnessRingsCard.tsx @@ -1,20 +1,14 @@ -import React from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; +import { useFocusEffect } from '@react-navigation/native'; import { CircularRing } from './CircularRing'; import { ROUTES } from '@/constants/Routes'; +import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health'; type FitnessRingsCardProps = { style?: any; - // 活动卡路里数据 - activeCalories?: number; - activeCaloriesGoal?: number; - // 锻炼分钟数据 - exerciseMinutes?: number; - exerciseMinutesGoal?: number; - // 站立小时数据 - standHours?: number; - standHoursGoal?: number; + selectedDate?: Date; // 动画重置令牌 resetToken?: unknown; }; @@ -24,14 +18,48 @@ type FitnessRingsCardProps = { */ export function FitnessRingsCard({ style, - activeCalories = 25, - activeCaloriesGoal = 350, - exerciseMinutes = 1, - exerciseMinutesGoal = 5, - standHours = 2, - standHoursGoal = 13, + selectedDate, resetToken, }: FitnessRingsCardProps) { + const [activityData, setActivityData] = useState(null); + const [loading, setLoading] = useState(false); + const loadingRef = useRef(false); + + // 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发 + useFocusEffect( + useCallback(() => { + const loadActivityData = async () => { + if (!selectedDate) return; + + // 防止重复请求 + if (loadingRef.current) return; + + try { + loadingRef.current = true; + setLoading(true); + const data = await fetchActivityRingsForDate(selectedDate); + setActivityData(data); + } catch (error) { + console.error('FitnessRingsCard: 获取健身圆环数据失败:', error); + setActivityData(null); + } finally { + setLoading(false); + loadingRef.current = false; + } + }; + + loadActivityData(); + }, [selectedDate]) + ); + + // 使用获取到的数据或默认值 + const activeCalories = activityData?.activeEnergyBurned ?? 0; + const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350; + const exerciseMinutes = activityData?.appleExerciseTime ?? 0; + const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30; + const standHours = activityData?.appleStandHours ?? 0; + const standHoursGoal = activityData?.appleStandHoursGoal ?? 12; + // 计算进度百分比 const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal)); const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal)); @@ -95,24 +123,42 @@ export function FitnessRingsCard({ - {Math.round(activeCalories)} - /{activeCaloriesGoal} + {loading ? ( + -- + ) : ( + <> + {Math.round(activeCalories)} + /{activeCaloriesGoal} + + )} 千卡 - {Math.round(exerciseMinutes)} - /{exerciseMinutesGoal} + {loading ? ( + -- + ) : ( + <> + {Math.round(exerciseMinutes)} + /{exerciseMinutesGoal} + + )} 分钟 - {Math.round(standHours)} - /{standHoursGoal} + {loading ? ( + -- + ) : ( + <> + {Math.round(standHours)} + /{standHoursGoal} + + )} 小时 diff --git a/components/statistic/SleepCard.tsx b/components/statistic/SleepCard.tsx index b60ed1d..a10f584 100644 --- a/components/statistic/SleepCard.tsx +++ b/components/statistic/SleepCard.tsx @@ -1,18 +1,19 @@ import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit'; +import dayjs from 'dayjs'; import { Image } from 'expo-image'; +import { router } from 'expo-router'; import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + interface SleepCardProps { selectedDate?: Date; style?: object; - onPress?: () => void; } const SleepCard: React.FC = ({ selectedDate, style, - onPress }) => { const [sleepDuration, setSleepDuration] = useState(null); const [loading, setLoading] = useState(false); @@ -52,15 +53,11 @@ const SleepCard: React.FC = ({ ); - if (onPress) { - return ( - - {CardContent} - - ); - } - - return CardContent; + return ( + router.push(`/sleep-detail?date=${dayjs(selectedDate).format('YYYY-MM-DD')}`)} activeOpacity={0.7}> + {CardContent} + + ); }; const styles = StyleSheet.create({ diff --git a/hooks/useHealthPermissions.ts b/hooks/useHealthPermissions.ts new file mode 100644 index 0000000..71383d6 --- /dev/null +++ b/hooks/useHealthPermissions.ts @@ -0,0 +1,236 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + healthPermissionManager, + HealthPermissionStatus, + ensureHealthPermissions, + checkHealthPermissionStatus, + fetchTodayHealthData, + fetchHealthDataForDate, + TodayHealthData +} from '@/utils/health'; + +export interface UseHealthPermissionsReturn { + // 权限状态 + permissionStatus: HealthPermissionStatus; + isLoading: boolean; + + // 权限操作 + requestPermissions: () => Promise; + checkPermissions: (forceCheck?: boolean) => Promise; + + // 数据刷新 + refreshHealthData: () => Promise; + refreshHealthDataForDate: (date: Date) => Promise; + + // 健康数据 + healthData: TodayHealthData | null; + + // 状态检查 + hasPermission: boolean; + needsPermission: boolean; +} + +/** + * HealthKit权限状态管理Hook + * + * 功能: + * 1. 监听权限状态变化 + * 2. 自动刷新数据当权限状态改变时 + * 3. 提供权限请求和数据刷新方法 + * 4. 缓存健康数据状态 + */ +export function useHealthPermissions(): UseHealthPermissionsReturn { + const [permissionStatus, setPermissionStatus] = useState( + healthPermissionManager.getPermissionStatus() + ); + const [isLoading, setIsLoading] = useState(false); + const [healthData, setHealthData] = useState(null); + + // 使用ref避免闭包问题 + const isLoadingRef = useRef(false); + const lastRefreshTime = useRef(0); + const refreshThrottle = 2000; // 2秒内避免重复刷新 + + // 刷新健康数据 + const refreshHealthData = useCallback(async () => { + const now = Date.now(); + + // 防抖:避免短时间内重复刷新 + if (isLoadingRef.current || (now - lastRefreshTime.current) < refreshThrottle) { + console.log('健康数据刷新被节流,跳过本次刷新'); + return; + } + + if (permissionStatus !== HealthPermissionStatus.Authorized) { + console.log('没有HealthKit权限,跳过数据刷新'); + return; + } + + isLoadingRef.current = true; + setIsLoading(true); + lastRefreshTime.current = now; + + try { + console.log('开始刷新今日健康数据...'); + const data = await fetchTodayHealthData(); + setHealthData(data); + console.log('健康数据刷新成功:', data); + } catch (error) { + console.error('刷新健康数据失败:', error); + } finally { + isLoadingRef.current = false; + setIsLoading(false); + } + }, [permissionStatus]); + + // 刷新指定日期的健康数据 + const refreshHealthDataForDate = useCallback(async (date: Date) => { + if (permissionStatus !== HealthPermissionStatus.Authorized) { + console.log('没有HealthKit权限,跳过数据刷新'); + return; + } + + setIsLoading(true); + try { + console.log('开始刷新指定日期健康数据...', date); + const data = await fetchHealthDataForDate(date); + // 只有是今天的数据才更新state + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + setHealthData(data); + } + console.log('指定日期健康数据刷新成功:', data); + } catch (error) { + console.error('刷新指定日期健康数据失败:', error); + } finally { + setIsLoading(false); + } + }, [permissionStatus]); + + // 请求权限 + const requestPermissions = useCallback(async (): Promise => { + setIsLoading(true); + try { + console.log('开始请求HealthKit权限...'); + const granted = await ensureHealthPermissions(); + + if (granted) { + console.log('权限请求成功,准备刷新数据'); + // 权限获取成功后,稍微延迟刷新数据 + setTimeout(() => { + refreshHealthData(); + }, 500); + } + + return granted; + } catch (error) { + console.error('请求HealthKit权限失败:', error); + return false; + } finally { + setIsLoading(false); + } + }, [refreshHealthData]); + + // 检查权限状态 + const checkPermissions = useCallback(async (forceCheck: boolean = false): Promise => { + try { + const status = await checkHealthPermissionStatus(forceCheck); + return status; + } catch (error) { + console.error('检查权限状态失败:', error); + return HealthPermissionStatus.Unknown; + } + }, []); + + // 监听权限状态变化 + useEffect(() => { + console.log('设置HealthKit权限状态监听器...'); + + // 权限状态变化监听 + const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus, oldStatus: HealthPermissionStatus) => { + console.log(`权限状态变化: ${oldStatus} -> ${newStatus}`); + setPermissionStatus(newStatus); + + // 如果从无权限变为有权限,自动刷新数据 + if (oldStatus !== HealthPermissionStatus.Authorized && newStatus === HealthPermissionStatus.Authorized) { + console.log('权限状态变为已授权,准备刷新健康数据...'); + setTimeout(() => { + refreshHealthData(); + }, 500); + } + }; + + // 权限获取成功监听 + const handlePermissionGranted = () => { + console.log('权限获取成功事件触发,准备刷新数据...'); + setTimeout(() => { + refreshHealthData(); + }, 500); + }; + + healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged); + healthPermissionManager.on('permissionGranted', handlePermissionGranted); + + // 组件挂载时检查一次权限状态 + checkPermissions(true); + + // 如果已经有权限,立即刷新数据 + if (permissionStatus === HealthPermissionStatus.Authorized) { + refreshHealthData(); + } + + return () => { + console.log('清理HealthKit权限状态监听器...'); + healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged); + healthPermissionManager.off('permissionGranted', handlePermissionGranted); + }; + }, [checkPermissions, refreshHealthData, permissionStatus]); + + // 计算派生状态 + const hasPermission = permissionStatus === HealthPermissionStatus.Authorized; + const needsPermission = permissionStatus === HealthPermissionStatus.NotDetermined || + permissionStatus === HealthPermissionStatus.Unknown; + + return { + permissionStatus, + isLoading, + requestPermissions, + checkPermissions, + refreshHealthData, + refreshHealthDataForDate, + healthData, + hasPermission, + needsPermission + }; +} + +/** + * 简化版Hook,只关注权限状态 + */ +export function useHealthPermissionStatus() { + const [permissionStatus, setPermissionStatus] = useState( + healthPermissionManager.getPermissionStatus() + ); + + useEffect(() => { + const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus) => { + setPermissionStatus(newStatus); + }; + + healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged); + + // 检查一次当前状态 + checkHealthPermissionStatus(true); + + return () => { + healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged); + }; + }, []); + + return { + permissionStatus, + hasPermission: permissionStatus === HealthPermissionStatus.Authorized, + needsPermission: permissionStatus === HealthPermissionStatus.NotDetermined || + permissionStatus === HealthPermissionStatus.Unknown + }; +} \ No newline at end of file diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index 84c88aa..7c3f747 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -55,4 +55,17 @@ RCT_EXTERN_METHOD(getDailyStepCountSamples:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +// Hourly Data Methods +RCT_EXTERN_METHOD(getHourlyActiveEnergyBurned:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getHourlyExerciseTime:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end \ No newline at end of file diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index 4795186..f4e7c8d 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -29,16 +29,16 @@ class HealthKitManager: NSObject, RCTBridgeModule { static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)! static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)! static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)! - static let restingHeartRate = HKObjectType.quantityType(forIdentifier: .restingHeartRate)! static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)! static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)! static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)! static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)! static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)! + static let activitySummary = HKObjectType.activitySummaryType() static var all: Set { - return [sleep, stepCount, heartRate, restingHeartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation] + return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary] } } @@ -566,9 +566,14 @@ class HealthKitManager: NSObject, RCTBridgeModule { } let calendar = Calendar.current - let anchoredDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate) + var startDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate) + var endDateComponents = calendar.dateComponents([.day, .month, .year], from: endDate) - let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: anchoredDateComponents, end: anchoredDateComponents) + // HealthKit requires DateComponents to have a calendar + startDateComponents.calendar = calendar + endDateComponents.calendar = calendar + + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: startDateComponents, end: endDateComponents) let query = HKActivitySummaryQuery(predicate: predicate) { [weak self] (query, summaries, error) in DispatchQueue.main.async { @@ -583,7 +588,10 @@ class HealthKitManager: NSObject, RCTBridgeModule { } let summaryData = summaries.map { summary in - [ + // 获取对应日期的 DateComponents + let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate) + + return [ "activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()), "activeEnergyBurnedGoal": summary.activeEnergyBurnedGoal.doubleValue(for: HKUnit.kilocalorie()), "appleExerciseTime": summary.appleExerciseTime.doubleValue(for: HKUnit.minute()), @@ -591,9 +599,9 @@ class HealthKitManager: NSObject, RCTBridgeModule { "appleStandHours": summary.appleStandHours.doubleValue(for: HKUnit.count()), "appleStandHoursGoal": summary.appleStandHoursGoal.doubleValue(for: HKUnit.count()), "dateComponents": [ - "day": anchoredDateComponents.day ?? 0, - "month": anchoredDateComponents.month ?? 0, - "year": anchoredDateComponents.year ?? 0 + "day": summaryDateComponents.day ?? 0, + "month": summaryDateComponents.month ?? 0, + "year": summaryDateComponents.year ?? 0 ] ] as [String : Any] } @@ -1060,5 +1068,269 @@ class HealthKitManager: NSObject, RCTBridgeModule { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: date) } - + + // MARK: - Hourly Data Methods + + @objc + func getHourlyActiveEnergyBurned( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let activeEnergyType = ReadTypes.activeEnergyBurned + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.startOfDay(for: Date()) + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let calendar = Calendar.current + let anchorDate = calendar.startOfDay(for: startDate) + let interval = DateComponents(hour: 1) + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + + let query = HKStatisticsCollectionQuery( + quantityType: activeEnergyType, + quantitySamplePredicate: predicate, + options: .cumulativeSum, + anchorDate: anchorDate, + intervalComponents: interval + ) + + query.initialResultsHandler = { [weak self] query, results, error in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query hourly active energy: \(error.localizedDescription)", error) + return + } + + guard let results = results else { + resolver([ + "data": [], + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + var hourlyData: [[String: Any]] = [] + + results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in + let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.kilocalorie()) ?? 0 + + let hourData: [String: Any] = [ + "startDate": self?.dateToISOString(statistics.startDate) ?? "", + "endDate": self?.dateToISOString(statistics.endDate) ?? "", + "value": value, + "hour": calendar.component(.hour, from: statistics.startDate) + ] + + hourlyData.append(hourData) + } + + let result: [String: Any] = [ + "data": hourlyData, + "count": hourlyData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + + healthStore.execute(query) + } + + @objc + func getHourlyExerciseTime( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let exerciseType = ReadTypes.appleExerciseTime + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.startOfDay(for: Date()) + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let calendar = Calendar.current + let anchorDate = calendar.startOfDay(for: startDate) + let interval = DateComponents(hour: 1) + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + + let query = HKStatisticsCollectionQuery( + quantityType: exerciseType, + quantitySamplePredicate: predicate, + options: .cumulativeSum, + anchorDate: anchorDate, + intervalComponents: interval + ) + + query.initialResultsHandler = { [weak self] query, results, error in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query hourly exercise time: \(error.localizedDescription)", error) + return + } + + guard let results = results else { + resolver([ + "data": [], + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + var hourlyData: [[String: Any]] = [] + + results.enumerateStatistics(from: startDate, to: endDate) { statistics, stop in + let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0 + + let hourData: [String: Any] = [ + "startDate": self?.dateToISOString(statistics.startDate) ?? "", + "endDate": self?.dateToISOString(statistics.endDate) ?? "", + "value": value, + "hour": calendar.component(.hour, from: statistics.startDate) + ] + + hourlyData.append(hourData) + } + + let result: [String: Any] = [ + "data": hourlyData, + "count": hourlyData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + + healthStore.execute(query) + } + + @objc + func getHourlyStandHours( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let standType = ReadTypes.appleStandTime + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.startOfDay(for: Date()) + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let calendar = Calendar.current + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + + let query = HKSampleQuery(sampleType: standType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query hourly stand hours: \(error.localizedDescription)", error) + return + } + + guard let standSamples = samples as? [HKCategorySample] else { + resolver([ + "data": [], + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + // 初始化24小时数据 + var hourlyData: [[String: Any]] = [] + + // 为每个小时创建数据结构 + for hour in 0..<24 { + let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))! + let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))! + + // 检查该小时是否有站立记录 + let standSamplesInHour = standSamples.filter { sample in + return sample.startDate >= hourStart && sample.startDate < hourEnd && + sample.value == HKCategoryValueAppleStandHour.stood.rawValue + } + + let hasStood = standSamplesInHour.count > 0 ? 1 : 0 + + let hourData: [String: Any] = [ + "startDate": self?.dateToISOString(hourStart) ?? "", + "endDate": self?.dateToISOString(hourEnd) ?? "", + "value": hasStood, + "hour": hour + ] + + hourlyData.append(hourData) + } + + let result: [String: Any] = [ + "data": hourlyData, + "count": hourlyData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + + healthStore.execute(query) + } + } // end class diff --git a/utils/SimpleEventEmitter.ts b/utils/SimpleEventEmitter.ts new file mode 100644 index 0000000..61e2ce6 --- /dev/null +++ b/utils/SimpleEventEmitter.ts @@ -0,0 +1,109 @@ +/** + * React Native兼容的EventEmitter实现 + * + * 提供与Node.js EventEmitter相似的API,但专门为React Native环境设计 + * 避免了对Node.js内置模块的依赖 + */ +export class SimpleEventEmitter { + private listeners: { [event: string]: ((...args: any[]) => void)[] } = {}; + + /** + * 添加事件监听器 + * @param event 事件名称 + * @param listener 监听器函数 + */ + on(event: string, listener: (...args: any[]) => void): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(listener); + } + + /** + * 移除事件监听器 + * @param event 事件名称 + * @param listener 要移除的监听器函数 + */ + off(event: string, listener: (...args: any[]) => void): void { + if (!this.listeners[event]) return; + + const index = this.listeners[event].indexOf(listener); + if (index > -1) { + this.listeners[event].splice(index, 1); + } + } + + /** + * 触发事件 + * @param event 事件名称 + * @param args 传递给监听器的参数 + */ + emit(event: string, ...args: any[]): void { + if (!this.listeners[event]) return; + + // 复制监听器数组,避免在执行过程中数组被修改 + const listeners = [...this.listeners[event]]; + + listeners.forEach(listener => { + try { + listener(...args); + } catch (error) { + console.error(`Error in event listener for event "${event}":`, error); + } + }); + } + + /** + * 添加一次性事件监听器 + * @param event 事件名称 + * @param listener 监听器函数(只会执行一次) + */ + once(event: string, listener: (...args: any[]) => void): void { + const onceWrapper = (...args: any[]) => { + this.off(event, onceWrapper); + listener(...args); + }; + + this.on(event, onceWrapper); + } + + /** + * 移除指定事件的所有监听器 + * @param event 事件名称(可选,如果未提供则移除所有事件的监听器) + */ + removeAllListeners(event?: string): void { + if (event) { + delete this.listeners[event]; + } else { + this.listeners = {}; + } + } + + /** + * 获取指定事件的监听器数量 + * @param event 事件名称 + * @returns 监听器数量 + */ + listenerCount(event: string): number { + return this.listeners[event]?.length || 0; + } + + /** + * 获取指定事件的所有监听器 + * @param event 事件名称 + * @returns 监听器数组的副本 + */ + listeners(event: string): ((...args: any[]) => void)[] { + return this.listeners[event] ? [...this.listeners[event]] : []; + } + + /** + * 获取所有事件名称 + * @returns 事件名称数组 + */ + eventNames(): string[] { + return Object.keys(this.listeners); + } +} + +export default SimpleEventEmitter; \ No newline at end of file diff --git a/utils/health.ts b/utils/health.ts index dfe19b1..3476003 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1,5 +1,6 @@ import dayjs from 'dayjs'; -import { NativeModules } from 'react-native'; +import { AppState, AppStateStatus, NativeModules } from 'react-native'; +import { SimpleEventEmitter } from './SimpleEventEmitter'; type HealthDataOptions = { startDate: string; @@ -9,6 +10,140 @@ type HealthDataOptions = { // React Native bridge to native HealthKitManager const { HealthKitManager } = NativeModules; +// HealthKit权限状态枚举 +export enum HealthPermissionStatus { + Unknown = 'unknown', + Authorized = 'authorized', + Denied = 'denied', + NotDetermined = 'notDetermined' +} + +// 权限状态管理类 +class HealthPermissionManager extends SimpleEventEmitter { + private permissionStatus: HealthPermissionStatus = HealthPermissionStatus.Unknown; + private isChecking: boolean = false; + private lastCheckTime: number = 0; + private checkInterval: number = 5000; // 5秒检查间隔,避免频繁检查 + private appStateSubscription: any = null; + + constructor() { + super(); + this.setupAppStateListener(); + } + + // 设置应用状态监听 + private setupAppStateListener() { + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange.bind(this)); + } + + // 处理应用状态变化 + private handleAppStateChange(nextAppState: AppStateStatus) { + if (nextAppState === 'active') { + // 应用回到前台时检查权限状态 + console.log('应用回到前台,检查HealthKit权限状态...'); + this.checkPermissionStatus(true); + } + } + + // 获取当前权限状态 + public getPermissionStatus(): HealthPermissionStatus { + return this.permissionStatus; + } + + // 设置权限状态 + private setPermissionStatus(status: HealthPermissionStatus, shouldEmit: boolean = true) { + const oldStatus = this.permissionStatus; + this.permissionStatus = status; + + if (shouldEmit && oldStatus !== status) { + console.log(`HealthKit权限状态变化: ${oldStatus} -> ${status}`); + this.emit('permissionStatusChanged', status, oldStatus); + } + } + + // 检查权限状态(通过尝试读取数据来间接判断) + public async checkPermissionStatus(forceCheck: boolean = false): Promise { + const now = Date.now(); + + // 避免频繁检查 + if (!forceCheck && this.isChecking) { + return this.permissionStatus; + } + + if (!forceCheck && (now - this.lastCheckTime) < this.checkInterval) { + return this.permissionStatus; + } + + this.isChecking = true; + this.lastCheckTime = now; + + try { + // 尝试获取简单的步数数据来检测权限 + const today = new Date(); + const options = { + startDate: dayjs(today).startOf('day').toDate().toISOString(), + endDate: dayjs(today).endOf('day').toDate().toISOString() + }; + + const result = await HealthKitManager.getStepCount(options); + + if (result && result.totalValue !== undefined) { + // 能够获取数据,说明有权限 + this.setPermissionStatus(HealthPermissionStatus.Authorized); + } else if (result && result.error) { + // 有错误返回,可能是权限被拒绝 + this.setPermissionStatus(HealthPermissionStatus.Denied); + } else { + // 其他情况 + this.setPermissionStatus(HealthPermissionStatus.Unknown); + } + } catch (error) { + console.log('HealthKit权限检查失败,可能是权限被拒绝:', error); + this.setPermissionStatus(HealthPermissionStatus.Denied); + } finally { + this.isChecking = false; + } + + return this.permissionStatus; + } + + // 请求权限 + public async requestPermission(): Promise { + try { + console.log('开始请求HealthKit权限...'); + const result = await HealthKitManager.requestAuthorization(); + + if (result && result.success) { + console.log('HealthKit权限请求成功'); + this.setPermissionStatus(HealthPermissionStatus.Authorized); + + // 权限获取成功后触发数据刷新事件 + this.emit('permissionGranted'); + return true; + } else { + console.error('HealthKit权限请求失败'); + this.setPermissionStatus(HealthPermissionStatus.Denied); + return false; + } + } catch (error) { + console.error('HealthKit权限请求出现异常:', error); + this.setPermissionStatus(HealthPermissionStatus.Denied); + return false; + } + } + + // 清理资源 + public destroy() { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + } + this.removeAllListeners(); + } +} + +// 全局权限管理实例 +export const healthPermissionManager = new HealthPermissionManager(); + // Interface for activity summary data from HealthKit export interface HealthActivitySummary { activeEnergyBurned: number; @@ -86,23 +221,19 @@ export type TodayHealthData = { heartRate: number | null; }; +// 更新:使用新的权限管理系统 export async function ensureHealthPermissions(): Promise { - try { - console.log('开始请求HealthKit权限...'); - const result = await HealthKitManager.requestAuthorization(); + return await healthPermissionManager.requestPermission(); +} - if (result && result.success) { - console.log('HealthKit权限请求成功'); - console.log('权限状态:', result.permissions); - return true; - } else { - console.error('HealthKit权限请求失败'); - return false; - } - } catch (error) { - console.error('HealthKit权限请求出现异常:', error); - return false; - } +// 获取当前权限状态 +export function getHealthPermissionStatus(): HealthPermissionStatus { + return healthPermissionManager.getPermissionStatus(); +} + +// 检查权限状态 +export async function checkHealthPermissionStatus(forceCheck: boolean = false): Promise { + return await healthPermissionManager.checkPermissionStatus(forceCheck); } // 日期工具函数 @@ -218,36 +349,105 @@ export async function fetchHourlyStepSamples(date: Date): Promise { +// 获取每小时活动热量数据 +async function fetchHourlyActiveCalories(date: Date): Promise { try { - // For now, return default data as hourly data is complex and not critical for basic fitness rings - console.log('每小时活动热量获取暂未实现,返回默认数据'); - return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 })); + const options = createDateRange(date); + const result = await HealthKitManager.getHourlyActiveEnergyBurned(options); + + if (result && result.data && Array.isArray(result.data)) { + logSuccess('每小时活动热量', result); + + // 初始化24小时数据 + const hourlyData: HourlyActivityData[] = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + calories: 0 + })); + + // 将API返回的数据映射到对应的小时 + result.data.forEach((sample: any) => { + if (sample && sample.hour !== undefined && sample.value !== undefined) { + const hour = sample.hour; + if (hour >= 0 && hour < 24) { + hourlyData[hour].calories = Math.round(sample.value); + } + } + }); + + return hourlyData; + } else { + logWarning('每小时活动热量', '为空或格式错误'); + return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 })); + } } catch (error) { logError('每小时活动热量', error); return Array.from({ length: 24 }, (_, i) => ({ hour: i, calories: 0 })); } } -// 获取每小时锻炼分钟数据(简化实现) -async function fetchHourlyExerciseMinutes(_date: Date): Promise { +// 获取每小时锻炼分钟数据 +async function fetchHourlyExerciseMinutes(date: Date): Promise { try { - // For now, return default data as hourly data is complex and not critical for basic fitness rings - console.log('每小时锻炼分钟获取暂未实现,返回默认数据'); - return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })); + const options = createDateRange(date); + const result = await HealthKitManager.getHourlyExerciseTime(options); + + if (result && result.data && Array.isArray(result.data)) { + logSuccess('每小时锻炼分钟', result); + + // 初始化24小时数据 + const hourlyData: HourlyExerciseData[] = Array.from({ length: 24 }, (_, i) => ({ + hour: i, + minutes: 0 + })); + + // 将API返回的数据映射到对应的小时 + result.data.forEach((sample: any) => { + if (sample && sample.hour !== undefined && sample.value !== undefined) { + const hour = sample.hour; + if (hour >= 0 && hour < 24) { + hourlyData[hour].minutes = Math.round(sample.value); + } + } + }); + + return hourlyData; + } else { + logWarning('每小时锻炼分钟', '为空或格式错误'); + return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })); + } } catch (error) { logError('每小时锻炼分钟', error); return Array.from({ length: 24 }, (_, i) => ({ hour: i, minutes: 0 })); } } -// 获取每小时站立小时数据(简化实现) -async function fetchHourlyStandHours(_date: Date): Promise { +// 获取每小时站立小时数据 +async function fetchHourlyStandHours(date: Date): Promise { try { - // For now, return default data as hourly data is complex and not critical for basic fitness rings - console.log('每小时站立数据获取暂未实现,返回默认数据'); - return Array.from({ length: 24 }, () => 0); + const options = createDateRange(date); + const result = await HealthKitManager.getHourlyStandHours(options); + + if (result && result.data && Array.isArray(result.data)) { + logSuccess('每小时站立数据', result); + + // 初始化24小时数据 + const hourlyData: number[] = Array.from({ length: 24 }, () => 0); + + // 将API返回的数据映射到对应的小时 + result.data.forEach((sample: any) => { + if (sample && sample.hour !== undefined && sample.value !== undefined) { + const hour = sample.hour; + if (hour >= 0 && hour < 24) { + hourlyData[hour] = sample.value; + } + } + }); + + return hourlyData; + } else { + logWarning('每小时站立数据', '为空或格式错误'); + return Array.from({ length: 24 }, () => 0); + } } catch (error) { logError('每小时站立数据', error); return Array.from({ length: 24 }, () => 0); @@ -314,15 +514,15 @@ async function fetchHeartRateVariability(options: HealthDataOptions): Promise { try { - // const result = await HealthKitManager.getActivitySummary(options); + const result = await HealthKitManager.getActivitySummary(options); - // if (result && Array.isArray(result) && result.length > 0) { - // logSuccess('ActivitySummary', result[0]); - // return result[0]; - // } else { - // logWarning('ActivitySummary', '为空'); - // return null; - // } + if (result && Array.isArray(result) && result.length > 0) { + logSuccess('ActivitySummary', result[0]); + return result[0]; + } else { + logWarning('ActivitySummary', '为空'); + return null; + } } catch (error) { logError('ActivitySummary', error); return null; @@ -366,8 +566,12 @@ async function fetchHeartRate(options: HealthDataOptions): Promise { +export async function fetchMaximumHeartRate(_options: HealthDataOptions): Promise { try { + // 暂未实现,返回null + console.log('最大心率获取暂未实现'); + return null; + // const result = await HealthKitManager.getHeartRateSamples(options); // if (result && result.data && Array.isArray(result.data) && result.data.length > 0) { @@ -536,10 +740,10 @@ export async function updateWeight(_weight: number) { } } -export async function testOxygenSaturationData(date: Date = dayjs().toDate()): Promise { +export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): Promise { console.log('=== 开始测试血氧饱和度数据获取 ==='); - const options = createDateRange(date); + // const options = createDateRange(date); try { // const result = await HealthKitManager.getOxygenSaturationSamples(options); @@ -697,3 +901,65 @@ export async function fetchActivityRingsForDate(date: Date): Promise { + healthPermissionManager.checkPermissionStatus(true); + }, 1000); +} + +// 监听权限状态变化(用于组件外部使用) +export function addHealthPermissionListener( + event: 'permissionStatusChanged' | 'permissionGranted', + listener: (...args: any[]) => void +) { + healthPermissionManager.on(event, listener); +} + +// 移除权限状态监听器 +export function removeHealthPermissionListener( + event: 'permissionStatusChanged' | 'permissionGranted', + listener: (...args: any[]) => void +) { + healthPermissionManager.off(event, listener); +} + +// 清理权限管理资源(应在应用退出时调用) +export function cleanupHealthPermissions() { + console.log('清理HealthKit权限管理资源...'); + healthPermissionManager.destroy(); +} + +// 获取权限状态的可读文本 +export function getPermissionStatusText(status: HealthPermissionStatus): string { + switch (status) { + case HealthPermissionStatus.Authorized: + return '已授权'; + case HealthPermissionStatus.Denied: + return '已拒绝'; + case HealthPermissionStatus.NotDetermined: + return '未确定'; + case HealthPermissionStatus.Unknown: + default: + return '未知'; + } +} + +// 检查是否需要显示权限请求UI +export function shouldShowPermissionRequest(): boolean { + const status = healthPermissionManager.getPermissionStatus(); + return status === HealthPermissionStatus.NotDetermined || + status === HealthPermissionStatus.Unknown; +} + +// 检查是否权限被用户拒绝 +export function isPermissionDenied(): boolean { + const status = healthPermissionManager.getPermissionStatus(); + return status === HealthPermissionStatus.Denied; +} + diff --git a/utils/healthKitExample.ts b/utils/healthKitExample.ts deleted file mode 100644 index 257fd73..0000000 --- a/utils/healthKitExample.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * HealthKit Native Module Usage Example - * 展示如何使用HealthKit native module的示例代码 - */ - -import HealthKitManager, { HealthKitUtils, SleepDataSample } from './healthKit'; - -export class HealthKitService { - - /** - * 初始化HealthKit并请求权限 - */ - static async initializeHealthKit(): Promise { - try { - // 检查HealthKit是否可用 - if (!HealthKitUtils.isAvailable()) { - console.log('HealthKit不可用,可能运行在Android设备或模拟器上'); - return false; - } - - // 请求授权 - const result = await HealthKitManager.requestAuthorization(); - - if (result.success) { - console.log('HealthKit授权成功'); - console.log('权限状态:', result.permissions); - - // 检查睡眠数据权限 - const sleepPermission = result.permissions['HKCategoryTypeIdentifierSleepAnalysis']; - if (sleepPermission === 'authorized') { - console.log('睡眠数据访问权限已获得'); - return true; - } else { - console.log('睡眠数据访问权限未获得:', sleepPermission); - return false; - } - } else { - console.log('HealthKit授权失败'); - return false; - } - } catch (error) { - console.error('HealthKit初始化失败:', error); - return false; - } - } - - /** - * 获取最近的睡眠数据 - */ - static async getRecentSleepData(days: number = 7): Promise { - try { - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(endDate.getDate() - days); - - const result = await HealthKitManager.getSleepData({ - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - limit: 100 - }); - - console.log(`获取到 ${result.count} 条睡眠记录`); - return result.data; - } catch (error) { - console.error('获取睡眠数据失败:', error); - return []; - } - } - - /** - * 分析睡眠质量 - */ - static async analyzeSleepQuality(days: number = 7): Promise { - try { - const sleepData = await this.getRecentSleepData(days); - - if (sleepData.length === 0) { - return { - error: '没有找到睡眠数据', - hasData: false - }; - } - - // 按日期分组 - const groupedData = HealthKitUtils.groupSamplesByDate(sleepData); - const dates = Object.keys(groupedData).sort().reverse(); // 最新的日期在前 - - const analysis = dates.slice(0, days).map(date => { - const daySamples = groupedData[date]; - const totalSleepDuration = HealthKitUtils.getTotalSleepDuration(daySamples, new Date(date)); - const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(daySamples); - - return { - date, - totalSleepDuration, - totalSleepFormatted: HealthKitUtils.formatDuration(totalSleepDuration), - qualityMetrics, - samplesCount: daySamples.length - }; - }); - - // 计算平均值 - const validDays = analysis.filter(day => day.totalSleepDuration > 0); - const averageSleepDuration = validDays.length > 0 - ? validDays.reduce((sum, day) => sum + day.totalSleepDuration, 0) / validDays.length - : 0; - - return { - hasData: true, - days: analysis, - summary: { - averageSleepDuration, - averageSleepFormatted: HealthKitUtils.formatDuration(averageSleepDuration), - daysWithData: validDays.length, - totalDaysAnalyzed: days - } - }; - } catch (error) { - console.error('睡眠质量分析失败:', error); - return { - error: error instanceof Error ? error.message : String(error), - hasData: false - }; - } - } - - /** - * 获取昨晚的睡眠数据 - */ - static async getLastNightSleep(): Promise { - try { - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(today.getDate() - 1); - - // 设置查询范围:昨天下午6点到今天上午12点 - const startDate = new Date(yesterday); - startDate.setHours(18, 0, 0, 0); - - const endDate = new Date(today); - endDate.setHours(12, 0, 0, 0); - - const result = await HealthKitManager.getSleepData({ - startDate: startDate.toISOString(), - endDate: endDate.toISOString(), - limit: 50 - }); - - if (result.data.length === 0) { - return { - hasData: false, - message: '未找到昨晚的睡眠数据' - }; - } - - const sleepSamples = result.data.filter(sample => - ['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType) - ); - - if (sleepSamples.length === 0) { - return { - hasData: false, - message: '未找到有效的睡眠阶段数据' - }; - } - - // 找到睡眠的开始和结束时间 - const sleepStart = new Date(Math.min(...sleepSamples.map(s => new Date(s.startDate).getTime()))); - const sleepEnd = new Date(Math.max(...sleepSamples.map(s => new Date(s.endDate).getTime()))); - const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0); - - const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(sleepSamples); - - return { - hasData: true, - sleepStart: sleepStart.toISOString(), - sleepEnd: sleepEnd.toISOString(), - totalDuration, - totalDurationFormatted: HealthKitUtils.formatDuration(totalDuration), - qualityMetrics, - samples: sleepSamples, - bedTime: sleepStart.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), - wakeTime: sleepEnd.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) - }; - } catch (error) { - console.error('获取昨晚睡眠数据失败:', error); - return { - hasData: false, - error: error instanceof Error ? error.message : String(error) - }; - } - } -} - -// 使用示例 -export const useHealthKitExample = async () => { - console.log('=== HealthKit 使用示例 ==='); - - // 1. 初始化和授权 - const initialized = await HealthKitService.initializeHealthKit(); - if (!initialized) { - console.log('HealthKit初始化失败,无法继续'); - return; - } - - // 2. 获取昨晚的睡眠数据 - console.log('\n--- 昨晚睡眠数据 ---'); - const lastNightSleep = await HealthKitService.getLastNightSleep(); - if (lastNightSleep.hasData) { - console.log(`睡眠时间: ${lastNightSleep.bedTime} - ${lastNightSleep.wakeTime}`); - console.log(`睡眠时长: ${lastNightSleep.totalDurationFormatted}`); - if (lastNightSleep.qualityMetrics) { - console.log(`深睡眠: ${lastNightSleep.qualityMetrics.deepSleepPercentage.toFixed(1)}%`); - console.log(`REM睡眠: ${lastNightSleep.qualityMetrics.remSleepPercentage.toFixed(1)}%`); - } - } else { - console.log(lastNightSleep.message || '未找到睡眠数据'); - } - - // 3. 分析最近一周的睡眠质量 - console.log('\n--- 最近一周睡眠分析 ---'); - const weeklyAnalysis = await HealthKitService.analyzeSleepQuality(7); - if (weeklyAnalysis.hasData) { - console.log(`平均睡眠时长: ${weeklyAnalysis.summary.averageSleepFormatted}`); - console.log(`有数据的天数: ${weeklyAnalysis.summary.daysWithData}/${weeklyAnalysis.summary.totalDaysAnalyzed}`); - - console.log('\n每日睡眠详情:'); - weeklyAnalysis.days.forEach((day: any) => { - if (day.totalSleepDuration > 0) { - console.log(`${day.date}: ${day.totalSleepFormatted}`); - } - }); - } else { - console.log(weeklyAnalysis.error || '睡眠分析失败'); - } -}; \ No newline at end of file