From 6039d0a778a042e5da7b8c5fcbdc64cc477ee7d8 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 19 Nov 2025 15:42:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(healthkit):=20=E5=AE=9E=E7=8E=B0HealthKit?= =?UTF-8?q?=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=90=8C=E6=AD=A5=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E8=BA=AB=E9=AB=98=E3=80=81=E4=BD=93=E9=87=8D=E5=92=8C=E5=87=BA?= =?UTF-8?q?=E7=94=9F=E6=97=A5=E6=9C=9F=E7=9A=84=E8=8E=B7=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/statistics.tsx | 40 +++ app/_layout.tsx | 17 +- app/profile/edit.tsx | 13 + ios/OutLive/HealthKitManager.m | 18 ++ ios/OutLive/HealthKitManager.swift | 308 +++++++++++++++++++- services/healthKitSync.ts | 447 +++++++++++++++++++++++++++++ services/notifications.ts | 30 ++ utils/health.ts | 164 ++++++++++- 8 files changed, 1029 insertions(+), 8 deletions(-) create mode 100644 services/healthKitSync.ts diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index ef27961..446269a 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -15,11 +15,14 @@ import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; +import { syncHealthKitToServer } from '@/services/healthKitSync'; import { setHealthData } from '@/store/healthSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; +import { updateUserProfile } from '@/store/userSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health'; +import { logger } from '@/utils/logger'; import dayjs from 'dayjs'; import { LinearGradient } from 'expo-linear-gradient'; import { debounce } from 'lodash'; @@ -58,6 +61,7 @@ const FloatingCard = ({ children, style }: { export default function ExploreScreen() { const { t } = useTranslation(); const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; + const userProfile = useAppSelector((s) => s.user.profile); const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard(); @@ -273,8 +277,44 @@ export default function ExploreScreen() { } }, [executeLoadAllData, debouncedLoadAllData]); + // 同步 HealthKit 数据到服务端(带智能 diff 比较) + const syncHealthDataToServer = React.useCallback(async () => { + if (!isLoggedIn || !userProfile) { + logger.info('用户未登录,跳过 HealthKit 数据同步'); + return; + } + + try { + logger.info('开始同步 HealthKit 个人健康数据到服务端...'); + + // 传入当前用户资料,用于 diff 比较 + const success = await syncHealthKitToServer( + async (data) => { + await dispatch(updateUserProfile(data) as any); + }, + userProfile // 传入当前用户资料进行比较 + ); + + if (success) { + logger.info('HealthKit 数据同步到服务端成功'); + } else { + logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败'); + } + } catch (error) { + logger.error('同步 HealthKit 数据到服务端失败:', error); + } + }, [isLoggedIn, dispatch, userProfile]); + + // 初始加载时执行数据加载和同步 useEffect(() => { loadAllData(currentSelectedDate); + + // 延迟1秒后执行同步,避免影响初始加载性能 + const syncTimer = setTimeout(() => { + syncHealthDataToServer(); + }, 1000); + + return () => clearTimeout(syncTimer); }, []) diff --git a/app/_layout.tsx b/app/_layout.tsx index aa2bace..264786a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,7 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useQuickActions } from '@/hooks/useQuickActions'; import { hrvMonitorService } from '@/services/hrvMonitor'; -import { notificationService } from '@/services/notifications'; +import { clearBadgeCount, notificationService } from '@/services/notifications'; import { setupQuickActions } from '@/services/quickActions'; import { sleepMonitorService } from '@/services/sleepMonitor'; import { initializeWaterRecordBridge } from '@/services/waterRecordBridge'; @@ -25,6 +25,7 @@ import { initializeHealthPermissions } from '@/utils/health'; import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import React, { useEffect } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; import { DialogProvider } from '@/components/ui/DialogProvider'; import { MembershipModalProvider } from '@/contexts/MembershipModalContext'; @@ -149,6 +150,20 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { initializeBasicServices(); }, [dispatch]); + // ==================== 应用状态监听 - 进入前台时清除角标 ==================== + React.useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + // 应用进入前台时清除角标 + clearBadgeCount(); + } + }); + + return () => { + subscription.remove(); + }; + }, []); + // ==================== 权限相关服务初始化(应用启动时执行)==================== React.useEffect(() => { // 如果已经初始化过,则跳过(确保只初始化一次) diff --git a/app/profile/edit.tsx b/app/profile/edit.tsx index 448435b..281a7e4 100644 --- a/app/profile/edit.tsx +++ b/app/profile/edit.tsx @@ -5,6 +5,7 @@ import { useColorScheme } from '@/hooks/useColorScheme'; import { useCosUpload } from '@/hooks/useCosUpload'; import { useI18n } from '@/hooks/useI18n'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; +import { syncServerToHealthKit } from '@/services/healthKitSync'; import { fetchMyProfile, updateUserProfile } from '@/store/userSlice'; import { fetchMaximumHeartRate } from '@/utils/health'; import AsyncStorage from '@/utils/kvStore'; @@ -212,6 +213,18 @@ export default function EditProfileScreen() { })); // 拉取最新用户信息,刷新全局状态 await dispatch(fetchMyProfile() as any); + + // 同步身高、体重到 HealthKit + console.log('开始同步个人健康数据到 HealthKit...'); + const syncSuccess = await syncServerToHealthKit({ + height: next.height, + weight: next.weight, + birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined + }); + + if (syncSuccess) { + console.log('个人健康数据已同步到 HealthKit'); + } } catch (e: any) { // 接口失败不阻断本地保存 console.warn('更新用户信息失败', e?.message || e); diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index 200d4e8..1d878a1 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -117,4 +117,22 @@ RCT_EXTERN_METHOD(startHRVObserver:(RCTPromiseResolveBlock)resolver RCT_EXTERN_METHOD(stopHRVObserver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +// Personal Health Data Methods +RCT_EXTERN_METHOD(getHeight:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getWeight:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(getDateOfBirth:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(saveHeight:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index 7861ad3..bd964cb 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -59,9 +59,18 @@ class HealthKitManager: RCTEventEmitter { static var workout: HKWorkoutType { return HKObjectType.workoutType() } + static var height: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .height) + } + static var bodyMass: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .bodyMass) + } + static var dateOfBirth: HKCharacteristicType { + return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)! + } static var all: Set { - var types: Set = [activitySummary, workout] + var types: Set = [activitySummary, workout, dateOfBirth] if let sleep = sleep { types.insert(sleep) } if let stepCount = stepCount { types.insert(stepCount) } if let heartRate = heartRate { types.insert(heartRate) } @@ -72,6 +81,8 @@ class HealthKitManager: RCTEventEmitter { if let appleStandTime = appleStandTime { types.insert(appleStandTime) } if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) } if let dietaryWater = dietaryWater { types.insert(dietaryWater) } + if let height = height { types.insert(height) } + if let bodyMass = bodyMass { types.insert(bodyMass) } return types } @@ -85,6 +96,9 @@ class HealthKitManager: RCTEventEmitter { static var bodyMass: HKQuantityType? { return HKObjectType.quantityType(forIdentifier: .bodyMass) } + static var height: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .height) + } static var dietaryWater: HKQuantityType? { return HKObjectType.quantityType(forIdentifier: .dietaryWater) } @@ -101,6 +115,7 @@ class HealthKitManager: RCTEventEmitter { static var all: Set { var types: Set = [] if let bodyMass = bodyMass { types.insert(bodyMass) } + if let height = height { types.insert(height) } if let dietaryWater = dietaryWater { types.insert(dietaryWater) } if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) } if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) } @@ -2242,6 +2257,297 @@ private func sendHRVUpdateEvent() { ]) } +// MARK: - Personal Health Data Methods (Height, Weight, Birth Date) + +/// 获取身高数据 +@objc +func getHeight( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + guard let heightType = ReadTypes.height else { + rejecter("TYPE_NOT_AVAILABLE", "Height type is not available", nil) + return + } + + // 查询最新的身高记录 + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let query = HKSampleQuery(sampleType: heightType, + predicate: nil, + limit: 1, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query height: \(error.localizedDescription)", error) + return + } + + guard let heightSamples = samples as? [HKQuantitySample], let latestHeight = heightSamples.first else { + resolver(["value": NSNull(), "unit": "cm"]) + return + } + + // 转换为厘米 + let heightInCm = latestHeight.quantity.doubleValue(for: HKUnit.meterUnit(with: .centi)) + + let result: [String: Any] = [ + "value": heightInCm, + "unit": "cm", + "recordedAt": self?.dateToISOString(latestHeight.endDate) ?? "", + "source": [ + "name": latestHeight.sourceRevision.source.name, + "bundleIdentifier": latestHeight.sourceRevision.source.bundleIdentifier + ] + ] + resolver(result) + } + } + healthStore.execute(query) +} + +/// 获取体重数据 +@objc +func getWeight( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + guard let weightType = ReadTypes.bodyMass else { + rejecter("TYPE_NOT_AVAILABLE", "Weight type is not available", nil) + return + } + + // 查询最新的体重记录 + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let query = HKSampleQuery(sampleType: weightType, + predicate: nil, + limit: 1, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query weight: \(error.localizedDescription)", error) + return + } + + guard let weightSamples = samples as? [HKQuantitySample], let latestWeight = weightSamples.first else { + resolver(["value": NSNull(), "unit": "kg"]) + return + } + + // 转换为千克 + let weightInKg = latestWeight.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)) + + let result: [String: Any] = [ + "value": weightInKg, + "unit": "kg", + "recordedAt": self?.dateToISOString(latestWeight.endDate) ?? "", + "source": [ + "name": latestWeight.sourceRevision.source.name, + "bundleIdentifier": latestWeight.sourceRevision.source.bundleIdentifier + ] + ] + resolver(result) + } + } + healthStore.execute(query) +} + +/// 获取出生日期(只读特性) +@objc +func getDateOfBirth( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + do { + let dateOfBirthComponents = try healthStore.dateOfBirthComponents() + + guard let year = dateOfBirthComponents.year, + let month = dateOfBirthComponents.month, + let day = dateOfBirthComponents.day else { + resolver(["value": NSNull()]) + return + } + + // 构造日期对象 + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = 0 + components.minute = 0 + components.second = 0 + + guard let date = Calendar.current.date(from: components) else { + resolver(["value": NSNull()]) + return + } + + let result: [String: Any] = [ + "value": dateToISOString(date), + "year": year, + "month": month, + "day": day + ] + resolver(result) + } catch { + rejecter("QUERY_ERROR", "Failed to query date of birth: \(error.localizedDescription)", error) + } +} + +/// 保存身高数据到 HealthKit +@objc +func saveHeight( + _ 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 + } + + guard let heightValue = options["value"] as? Double else { + rejecter("INVALID_PARAMETERS", "Height value is required", nil) + return + } + + let unit = options["unit"] as? String ?? "cm" + + guard let heightType = WriteTypes.height else { + rejecter("TYPE_NOT_AVAILABLE", "Height type is not available", nil) + return + } + + // 根据单位创建数量对象 + let heightQuantity: HKQuantity + if unit == "m" { + heightQuantity = HKQuantity(unit: HKUnit.meter(), doubleValue: heightValue) + } else { + // 默认使用厘米 + heightQuantity = HKQuantity(unit: HKUnit.meterUnit(with: .centi), doubleValue: heightValue) + } + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + // 创建身高样本 + let heightSample = HKQuantitySample( + type: heightType, + quantity: heightQuantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // 保存到 HealthKit + healthStore.save(heightSample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save height: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "value": heightValue, + "unit": unit, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save height", nil) + } + } + } +} + +/// 保存体重数据到 HealthKit +@objc +func saveWeight( + _ 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 + } + + guard let weightValue = options["value"] as? Double else { + rejecter("INVALID_PARAMETERS", "Weight value is required", nil) + return + } + + let unit = options["unit"] as? String ?? "kg" + + guard let weightType = WriteTypes.bodyMass else { + rejecter("TYPE_NOT_AVAILABLE", "Weight type is not available", nil) + return + } + + // 根据单位创建数量对象,默认使用千克 + let weightQuantity = HKQuantity(unit: HKUnit.gramUnit(with: .kilo), doubleValue: weightValue) + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + // 创建体重样本 + let weightSample = HKQuantitySample( + type: weightType, + quantity: weightQuantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // 保存到 HealthKit + healthStore.save(weightSample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save weight: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "value": weightValue, + "unit": unit, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save weight", nil) + } + } + } +} + // MARK: - RCTEventEmitter Overrides override func supportedEvents() -> [String]! { diff --git a/services/healthKitSync.ts b/services/healthKitSync.ts new file mode 100644 index 0000000..bf18e51 --- /dev/null +++ b/services/healthKitSync.ts @@ -0,0 +1,447 @@ +/** + * HealthKit 数据同步服务 + * + * 实现 HealthKit 与服务端之间的双向数据同步 + * - 从 HealthKit 同步身高、体重、出生日期到服务端 + * - 从服务端同步身高、体重、出生日期到 HealthKit + * - 防止循环同步的机制 + */ + +import { + fetchPersonalHealthData, + saveHeight, + saveWeight +} from '@/utils/health'; +import AsyncStorage from '@/utils/kvStore'; +import dayjs from 'dayjs'; + +// 同步状态存储键 +const SYNC_STATUS_KEY = '@healthkit_sync_status'; +const SYNC_LOCK_KEY = '@healthkit_sync_lock'; + +// 同步状态类型 +interface SyncStatus { + lastSyncTime: number; + lastSyncDirection: 'healthkit_to_server' | 'server_to_healthkit' | null; + lastSyncData: { + height?: number; + weight?: number; + birthDate?: string; + }; +} + +// 同步锁(防止并发同步) +let syncLock = false; + +/** + * 获取同步锁 + */ +async function acquireSyncLock(): Promise { + if (syncLock) { + console.log('同步操作正在进行中,跳过'); + return false; + } + + // 检查持久化的锁(防止应用重启后的并发) + const persistedLock = await AsyncStorage.getItem(SYNC_LOCK_KEY); + if (persistedLock) { + const lockTime = parseInt(persistedLock); + const now = Date.now(); + // 如果锁超过5分钟,认为是过期锁,可以清除 + if (now - lockTime < 5 * 60 * 1000) { + console.log('检测到持久化同步锁,跳过'); + return false; + } else { + console.log('清除过期的同步锁'); + await AsyncStorage.removeItem(SYNC_LOCK_KEY); + } + } + + syncLock = true; + await AsyncStorage.setItem(SYNC_LOCK_KEY, Date.now().toString()); + return true; +} + +/** + * 释放同步锁 + */ +async function releaseSyncLock(): Promise { + syncLock = false; + await AsyncStorage.removeItem(SYNC_LOCK_KEY); +} + +/** + * 获取上次同步状态 + */ +async function getLastSyncStatus(): Promise { + try { + const status = await AsyncStorage.getItem(SYNC_STATUS_KEY); + if (status) { + return JSON.parse(status); + } + return null; + } catch (error) { + console.error('获取同步状态失败:', error); + return null; + } +} + +/** + * 保存同步状态 + */ +async function saveSyncStatus(status: SyncStatus): Promise { + try { + await AsyncStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status)); + } catch (error) { + console.error('保存同步状态失败:', error); + } +} + +/** + * 判断数据是否需要同步(比较数据是否有变化) + */ +function shouldSyncData( + currentData: { height?: number; weight?: number; birthDate?: string }, + lastSyncData: { height?: number; weight?: number; birthDate?: string } +): boolean { + // 身高变化(允许1cm误差) + if (currentData.height && lastSyncData.height) { + if (Math.abs(currentData.height - lastSyncData.height) > 1) { + return true; + } + } else if (currentData.height !== lastSyncData.height) { + return true; + } + + // 体重变化(允许0.1kg误差) + if (currentData.weight && lastSyncData.weight) { + if (Math.abs(currentData.weight - lastSyncData.weight) > 0.1) { + return true; + } + } else if (currentData.weight !== lastSyncData.weight) { + return true; + } + + // 出生日期变化 + if (currentData.birthDate !== lastSyncData.birthDate) { + // 出生日期通常不会变,但仍然检查 + if (currentData.birthDate && lastSyncData.birthDate) { + const date1 = dayjs(currentData.birthDate).format('YYYY-MM-DD'); + const date2 = dayjs(lastSyncData.birthDate).format('YYYY-MM-DD'); + if (date1 !== date2) { + return true; + } + } else if (currentData.birthDate || lastSyncData.birthDate) { + return true; + } + } + + return false; +} + +/** + * 从 HealthKit 同步数据到服务端 + * + * @param updateUserProfile - 更新用户资料的函数(从 Redux userSlice) + * @param currentUserProfile - 当前用户资料(用于 diff 比较) + * @returns 是否成功同步 + */ +export async function syncHealthKitToServer( + updateUserProfile: (data: { + height?: number; + weight?: number; + birthDate?: number; // Unix timestamp in seconds + }) => Promise, + currentUserProfile?: { + height?: string | number; + weight?: string | number; + birthDate?: string | number; + } +): Promise { + console.log('=== 开始从 HealthKit 同步数据到服务端 ==='); + + // 获取同步锁 + const acquired = await acquireSyncLock(); + if (!acquired) { + return false; + } + + try { + // 获取上次同步状态 + const lastSync = await getLastSyncStatus(); + + // 检查是否刚刚从服务端同步过(防止循环) + if (lastSync?.lastSyncDirection === 'server_to_healthkit') { + const timeSinceLastSync = Date.now() - lastSync.lastSyncTime; + // 如果2分钟内刚从服务端同步过,跳过本次同步 + if (timeSinceLastSync < 2 * 60 * 1000) { + console.log('刚从服务端同步过数据,跳过 HealthKit -> 服务端同步'); + return false; + } + } + + // 从 HealthKit 获取数据 + const healthData = await fetchPersonalHealthData(); + console.log('从 HealthKit 获取的数据:', healthData); + + // 检查是否有数据需要同步 + if (!healthData.height && !healthData.weight && !healthData.dateOfBirth) { + console.log('HealthKit 中没有个人健康数据,跳过同步'); + return false; + } + + // 准备当前 HealthKit 数据 + const currentHealthKitData = { + height: healthData.height || undefined, + weight: healthData.weight || undefined, + birthDate: healthData.dateOfBirth || undefined + }; + + // 1. 首先与服务端当前数据进行比较(如果提供了) + if (currentUserProfile) { + // 转换服务端数据格式以便比较 + const serverData = { + height: currentUserProfile.height ? + (typeof currentUserProfile.height === 'string' ? + parseFloat(currentUserProfile.height) : + currentUserProfile.height) : + undefined, + weight: currentUserProfile.weight ? + (typeof currentUserProfile.weight === 'string' ? + parseFloat(currentUserProfile.weight) : + currentUserProfile.weight) : + undefined, + birthDate: currentUserProfile.birthDate ? + (typeof currentUserProfile.birthDate === 'number' ? + new Date(currentUserProfile.birthDate * 1000).toISOString() : + currentUserProfile.birthDate) : + undefined + }; + + const needsSyncWithServer = shouldSyncData(currentHealthKitData, serverData); + if (!needsSyncWithServer) { + console.log('HealthKit 数据与服务端数据一致,跳过同步'); + // 更新同步状态(即使没有同步,也记录检查时间) + await saveSyncStatus({ + lastSyncTime: Date.now(), + lastSyncDirection: 'healthkit_to_server', + lastSyncData: currentHealthKitData + }); + return false; + } + console.log('检测到 HealthKit 数据与服务端数据存在差异,需要同步'); + } + + // 2. 然后与上次同步的数据进行比较 + if (lastSync?.lastSyncData) { + const needsSync = shouldSyncData(currentHealthKitData, lastSync.lastSyncData); + + if (!needsSync) { + console.log('数据与上次同步时一致,跳过同步'); + return false; + } + } + + // 准备同步到服务端的数据 + const serverData: { + height?: number; + weight?: number; + birthDate?: number; + } = {}; + + if (healthData.height) { + serverData.height = healthData.height; + } + + if (healthData.weight) { + serverData.weight = healthData.weight; + } + + if (healthData.dateOfBirth) { + // 将 ISO 字符串转换为 Unix 时间戳(秒) + const timestamp = new Date(healthData.dateOfBirth).getTime() / 1000; + serverData.birthDate = Math.floor(timestamp); + } + + // 同步到服务端 + console.log('准备同步到服务端的数据:', serverData); + await updateUserProfile(serverData); + console.log('成功同步数据到服务端'); + + // 保存同步状态 + await saveSyncStatus({ + lastSyncTime: Date.now(), + lastSyncDirection: 'healthkit_to_server', + lastSyncData: { + height: healthData.height || undefined, + weight: healthData.weight || undefined, + birthDate: healthData.dateOfBirth || undefined + } + }); + + return true; + } catch (error) { + console.error('从 HealthKit 同步数据到服务端失败:', error); + return false; + } finally { + // 释放同步锁 + await releaseSyncLock(); + } +} + +/** + * 从服务端同步数据到 HealthKit + * + * @param serverData - 服务端的用户数据 + * @returns 是否成功同步 + */ +export async function syncServerToHealthKit( + serverData: { + height?: number; + weight?: number; + birthDate?: number | string; // Unix timestamp (seconds) or ISO string + } +): Promise { + console.log('=== 开始从服务端同步数据到 HealthKit ==='); + + // 获取同步锁 + const acquired = await acquireSyncLock(); + if (!acquired) { + return false; + } + + try { + // 获取上次同步状态 + const lastSync = await getLastSyncStatus(); + + // 检查是否刚刚从 HealthKit 同步过(防止循环) + if (lastSync?.lastSyncDirection === 'healthkit_to_server') { + const timeSinceLastSync = Date.now() - lastSync.lastSyncTime; + // 如果2分钟内刚从 HealthKit 同步过,跳过本次同步 + if (timeSinceLastSync < 2 * 60 * 1000) { + console.log('刚从 HealthKit 同步过数据,跳过 服务端 -> HealthKit 同步'); + return false; + } + } + + // 准备要同步的数据 + const syncData: { + height?: number; + weight?: number; + birthDate?: string; + } = {}; + + if (serverData.height) { + // 确保身高是数字类型 + syncData.height = typeof serverData.height === 'string' + ? parseFloat(serverData.height) + : serverData.height; + } + + if (serverData.weight) { + // 确保体重是数字类型 + syncData.weight = typeof serverData.weight === 'string' + ? parseFloat(serverData.weight) + : serverData.weight; + } + + if (serverData.birthDate) { + // 如果是时间戳,转换为 ISO 字符串 + if (typeof serverData.birthDate === 'number') { + syncData.birthDate = new Date(serverData.birthDate * 1000).toISOString(); + } else { + syncData.birthDate = serverData.birthDate; + } + } + + console.log('准备同步到 HealthKit 的数据:', syncData); + + // 检查数据是否有变化 + if (lastSync?.lastSyncData) { + const needsSync = shouldSyncData(syncData, lastSync.lastSyncData); + + if (!needsSync) { + console.log('数据未变化,跳过同步'); + return false; + } + } + + // 同步到 HealthKit + const results: boolean[] = []; + + if (syncData.height) { + console.log('同步身高到 HealthKit:', syncData.height, 'cm'); + const success = await saveHeight(syncData.height); + results.push(success); + if (success) { + console.log('身高同步成功'); + } else { + console.error('身高同步失败'); + } + } + + if (syncData.weight) { + // 确保体重值是数字类型 + const weightValue = typeof syncData.weight === 'string' + ? parseFloat(syncData.weight) + : syncData.weight; + + console.log('同步体重到 HealthKit:', weightValue, 'kg'); + const success = await saveWeight(weightValue); + results.push(success); + if (success) { + console.log('体重同步成功'); + } else { + console.error('体重同步失败'); + } + } + + // 注意:出生日期在 HealthKit 中是只读的,无法写入 + if (syncData.birthDate) { + console.log('注意:出生日期在 HealthKit 中是只读的,无法同步'); + } + + // 如果至少有一项同步成功,则认为同步成功 + const success = results.length > 0 && results.some(r => r); + + if (success) { + // 保存同步状态 + await saveSyncStatus({ + lastSyncTime: Date.now(), + lastSyncDirection: 'server_to_healthkit', + lastSyncData: syncData + }); + + console.log('成功从服务端同步数据到 HealthKit'); + } + + return success; + } catch (error) { + console.error('从服务端同步数据到 HealthKit 失败:', error); + return false; + } finally { + // 释放同步锁 + await releaseSyncLock(); + } +} + +/** + * 清除同步状态(用于测试或重置) + */ +export async function clearSyncStatus(): Promise { + try { + await AsyncStorage.removeItem(SYNC_STATUS_KEY); + await AsyncStorage.removeItem(SYNC_LOCK_KEY); + syncLock = false; + console.log('已清除同步状态'); + } catch (error) { + console.error('清除同步状态失败:', error); + } +} + +/** + * 获取同步状态信息(用于调试) + */ +export async function getSyncStatusInfo(): Promise { + return getLastSyncStatus(); +} \ No newline at end of file diff --git a/services/notifications.ts b/services/notifications.ts index 13d9081..84061fb 100644 --- a/services/notifications.ts +++ b/services/notifications.ts @@ -14,6 +14,30 @@ Notifications.setNotificationHandler({ }), }); +/** + * 清除应用角标 + */ +export async function clearBadgeCount(): Promise { + try { + await Notifications.setBadgeCountAsync(0); + console.log('✅ 应用角标已清除'); + } catch (error) { + console.error('❌ 清除应用角标失败:', error); + } +} + +/** + * 获取当前角标数量 + */ +export async function getBadgeCount(): Promise { + try { + return await Notifications.getBadgeCountAsync(); + } catch (error) { + console.error('❌ 获取角标数量失败:', error); + return 0; + } +} + export interface NotificationData { title: string; body: string; @@ -110,6 +134,9 @@ export class NotificationService { return; } + // 清除应用角标(应用启动时) + await clearBadgeCount(); + // 设置通知监听器 this.setupNotificationListeners(); @@ -151,6 +178,9 @@ export class NotificationService { console.log('处理通知点击:', data); + // 用户点击通知后清除角标 + clearBadgeCount(); + // 根据通知类型处理不同的逻辑 if (data?.type === 'workout_reminder') { // 处理运动提醒 diff --git a/utils/health.ts b/utils/health.ts index 1a6ff67..2db42c1 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1061,18 +1061,170 @@ export async function testHRVDataFetch(date: Date = dayjs().toDate()): Promise { try { - // Note: Weight saving would need to be implemented in native module - console.log('体重保存到HealthKit暂未实现'); - return true; // Return true for now to not break existing functionality + console.log('开始从 HealthKit 获取身高...'); + const result = await HealthKitManager.getHeight(); + + if (result && typeof result.value === 'number') { + const heightInCm = Math.round(result.value); + console.log('成功获取身高:', heightInCm, 'cm'); + return heightInCm; + } else { + console.log('未找到身高数据'); + return null; + } } catch (error) { - console.error('更新体重失败:', error); + console.error('获取身高失败:', error); + return null; + } +} + +/** + * 从 HealthKit 获取体重(单位:千克) + */ +export async function fetchWeight(): Promise { + try { + console.log('开始从 HealthKit 获取体重...'); + const result = await HealthKitManager.getWeight(); + + if (result && typeof result.value === 'number') { + const weightInKg = Math.round(result.value * 10) / 10; // 保留1位小数 + console.log('成功获取体重:', weightInKg, 'kg'); + return weightInKg; + } else { + console.log('未找到体重数据'); + return null; + } + } catch (error) { + console.error('获取体重失败:', error); + return null; + } +} + +/** + * 从 HealthKit 获取出生日期 + */ +export async function fetchDateOfBirth(): Promise { + try { + console.log('开始从 HealthKit 获取出生日期...'); + const result = await HealthKitManager.getDateOfBirth(); + + if (result && typeof result.value === 'string') { + console.log('成功获取出生日期:', result.value); + return result.value; + } else { + console.log('未找到出生日期数据'); + return null; + } + } catch (error) { + console.error('获取出生日期失败:', error); + return null; + } +} + +/** + * 保存身高到 HealthKit + * @param heightInCm 身高(单位:厘米) + * @param unit 单位,默认为 'cm'(厘米),也支持 'in'(英寸) + */ +export async function saveHeight(heightInCm: number, unit: 'cm' | 'in' = 'cm'): Promise { + try { + console.log('开始保存身高到 HealthKit...', { heightInCm, unit }); + + if (heightInCm <= 0) { + console.error('身高值无效:', heightInCm); + return false; + } + + const options = { + value: heightInCm, + unit: unit + }; + + const result = await HealthKitManager.saveHeight(options); + + if (result && result.success) { + console.log('身高保存成功'); + return true; + } else { + console.error('身高保存失败:', result); + return false; + } + } catch (error) { + console.error('保存身高到 HealthKit 失败:', error); return false; } } +/** + * 保存体重到 HealthKit + * @param weightInKg 体重(单位:千克) + */ +export async function saveWeight(weightInKg: number): Promise { + try { + console.log('开始保存体重到 HealthKit...', { weightInKg }); + + if (weightInKg <= 0) { + console.error('体重值无效:', weightInKg); + return false; + } + + const options = { + value: weightInKg + }; + + const result = await HealthKitManager.saveWeight(options); + + if (result && result.success) { + console.log('体重保存成功'); + return true; + } else { + console.error('体重保存失败:', result); + return false; + } + } catch (error) { + console.error('保存体重到 HealthKit 失败:', error); + return false; + } +} + +// 保持向后兼容的旧函数名 +export async function updateWeight(weight: number): Promise { + return saveWeight(weight); +} + +/** + * 批量获取个人健康数据(身高、体重、出生日期) + */ +export async function fetchPersonalHealthData(): Promise<{ + height: number | null; + weight: number | null; + dateOfBirth: string | null; +}> { + try { + console.log('开始批量获取个人健康数据...'); + + const [height, weight, dateOfBirth] = await Promise.all([ + fetchHeight(), + fetchWeight(), + fetchDateOfBirth() + ]); + + console.log('个人健康数据获取完成:', { height, weight, dateOfBirth }); + + return { height, weight, dateOfBirth }; + } catch (error) { + console.error('批量获取个人健康数据失败:', error); + return { height: null, weight: null, dateOfBirth: null }; + } +} + export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): Promise { console.log('=== 开始测试血氧饱和度数据获取 ===');