feat(healthkit): 实现HealthKit与服务端的双向数据同步,包括身高、体重和出生日期的获取与保存
This commit is contained in:
@@ -15,11 +15,14 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||||
|
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||||
import { setHealthData } from '@/store/healthSlice';
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
|
import { updateUserProfile } from '@/store/userSlice';
|
||||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -58,6 +61,7 @@ const FloatingCard = ({ children, style }: {
|
|||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
@@ -273,8 +277,44 @@ export default function ExploreScreen() {
|
|||||||
}
|
}
|
||||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadAllData(currentSelectedDate);
|
loadAllData(currentSelectedDate);
|
||||||
|
|
||||||
|
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||||
|
const syncTimer = setTimeout(() => {
|
||||||
|
syncHealthDataToServer();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(syncTimer);
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||||
import { hrvMonitorService } from '@/services/hrvMonitor';
|
import { hrvMonitorService } from '@/services/hrvMonitor';
|
||||||
import { notificationService } from '@/services/notifications';
|
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
||||||
import { setupQuickActions } from '@/services/quickActions';
|
import { setupQuickActions } from '@/services/quickActions';
|
||||||
import { sleepMonitorService } from '@/services/sleepMonitor';
|
import { sleepMonitorService } from '@/services/sleepMonitor';
|
||||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
@@ -25,6 +25,7 @@ import { initializeHealthPermissions } from '@/utils/health';
|
|||||||
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||||
@@ -149,6 +150,20 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
initializeBasicServices();
|
initializeBasicServices();
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// ==================== 应用状态监听 - 进入前台时清除角标 ====================
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
// 应用进入前台时清除角标
|
||||||
|
clearBadgeCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ==================== 权限相关服务初始化(应用启动时执行)====================
|
// ==================== 权限相关服务初始化(应用启动时执行)====================
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// 如果已经初始化过,则跳过(确保只初始化一次)
|
// 如果已经初始化过,则跳过(确保只初始化一次)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
import { syncServerToHealthKit } from '@/services/healthKitSync';
|
||||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||||
import { fetchMaximumHeartRate } from '@/utils/health';
|
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||||
import AsyncStorage from '@/utils/kvStore';
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
@@ -212,6 +213,18 @@ export default function EditProfileScreen() {
|
|||||||
}));
|
}));
|
||||||
// 拉取最新用户信息,刷新全局状态
|
// 拉取最新用户信息,刷新全局状态
|
||||||
await dispatch(fetchMyProfile() as any);
|
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) {
|
} catch (e: any) {
|
||||||
// 接口失败不阻断本地保存
|
// 接口失败不阻断本地保存
|
||||||
console.warn('更新用户信息失败', e?.message || e);
|
console.warn('更新用户信息失败', e?.message || e);
|
||||||
|
|||||||
@@ -117,4 +117,22 @@ RCT_EXTERN_METHOD(startHRVObserver:(RCTPromiseResolveBlock)resolver
|
|||||||
RCT_EXTERN_METHOD(stopHRVObserver:(RCTPromiseResolveBlock)resolver
|
RCT_EXTERN_METHOD(stopHRVObserver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
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
|
@end
|
||||||
|
|||||||
@@ -59,9 +59,18 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
static var workout: HKWorkoutType {
|
static var workout: HKWorkoutType {
|
||||||
return HKObjectType.workoutType()
|
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<HKObjectType> {
|
static var all: Set<HKObjectType> {
|
||||||
var types: Set<HKObjectType> = [activitySummary, workout]
|
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||||
if let sleep = sleep { types.insert(sleep) }
|
if let sleep = sleep { types.insert(sleep) }
|
||||||
if let stepCount = stepCount { types.insert(stepCount) }
|
if let stepCount = stepCount { types.insert(stepCount) }
|
||||||
if let heartRate = heartRate { types.insert(heartRate) }
|
if let heartRate = heartRate { types.insert(heartRate) }
|
||||||
@@ -72,6 +81,8 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
|
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
|
||||||
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
|
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
|
||||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||||
|
if let height = height { types.insert(height) }
|
||||||
|
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||||
return types
|
return types
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +96,9 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
static var bodyMass: HKQuantityType? {
|
static var bodyMass: HKQuantityType? {
|
||||||
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
||||||
}
|
}
|
||||||
|
static var height: HKQuantityType? {
|
||||||
|
return HKObjectType.quantityType(forIdentifier: .height)
|
||||||
|
}
|
||||||
static var dietaryWater: HKQuantityType? {
|
static var dietaryWater: HKQuantityType? {
|
||||||
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
|
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
|
||||||
}
|
}
|
||||||
@@ -101,6 +115,7 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
static var all: Set<HKSampleType> {
|
static var all: Set<HKSampleType> {
|
||||||
var types: Set<HKSampleType> = []
|
var types: Set<HKSampleType> = []
|
||||||
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||||
|
if let height = height { types.insert(height) }
|
||||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||||
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
||||||
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
|
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
|
// MARK: - RCTEventEmitter Overrides
|
||||||
|
|
||||||
override func supportedEvents() -> [String]! {
|
override func supportedEvents() -> [String]! {
|
||||||
|
|||||||
447
services/healthKitSync.ts
Normal file
447
services/healthKitSync.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
syncLock = false;
|
||||||
|
await AsyncStorage.removeItem(SYNC_LOCK_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上次同步状态
|
||||||
|
*/
|
||||||
|
async function getLastSyncStatus(): Promise<SyncStatus | null> {
|
||||||
|
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<void> {
|
||||||
|
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<any>,
|
||||||
|
currentUserProfile?: {
|
||||||
|
height?: string | number;
|
||||||
|
weight?: string | number;
|
||||||
|
birthDate?: string | number;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<SyncStatus | null> {
|
||||||
|
return getLastSyncStatus();
|
||||||
|
}
|
||||||
@@ -14,6 +14,30 @@ Notifications.setNotificationHandler({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除应用角标
|
||||||
|
*/
|
||||||
|
export async function clearBadgeCount(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Notifications.setBadgeCountAsync(0);
|
||||||
|
console.log('✅ 应用角标已清除');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 清除应用角标失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前角标数量
|
||||||
|
*/
|
||||||
|
export async function getBadgeCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await Notifications.getBadgeCountAsync();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 获取角标数量失败:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationData {
|
export interface NotificationData {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -110,6 +134,9 @@ export class NotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除应用角标(应用启动时)
|
||||||
|
await clearBadgeCount();
|
||||||
|
|
||||||
// 设置通知监听器
|
// 设置通知监听器
|
||||||
this.setupNotificationListeners();
|
this.setupNotificationListeners();
|
||||||
|
|
||||||
@@ -151,6 +178,9 @@ export class NotificationService {
|
|||||||
|
|
||||||
console.log('处理通知点击:', data);
|
console.log('处理通知点击:', data);
|
||||||
|
|
||||||
|
// 用户点击通知后清除角标
|
||||||
|
clearBadgeCount();
|
||||||
|
|
||||||
// 根据通知类型处理不同的逻辑
|
// 根据通知类型处理不同的逻辑
|
||||||
if (data?.type === 'workout_reminder') {
|
if (data?.type === 'workout_reminder') {
|
||||||
// 处理运动提醒
|
// 处理运动提醒
|
||||||
|
|||||||
164
utils/health.ts
164
utils/health.ts
@@ -1061,16 +1061,168 @@ export async function testHRVDataFetch(date: Date = dayjs().toDate()): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新healthkit中的体重 (暂未实现)
|
// === 个人健康数据读取和写入方法 ===
|
||||||
export async function updateWeight(_weight: number) {
|
|
||||||
|
/**
|
||||||
|
* 从 HealthKit 获取身高(单位:厘米)
|
||||||
|
*/
|
||||||
|
export async function fetchHeight(): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
// Note: Weight saving would need to be implemented in native module
|
console.log('开始从 HealthKit 获取身高...');
|
||||||
console.log('体重保存到HealthKit暂未实现');
|
const result = await HealthKitManager.getHeight();
|
||||||
return true; // Return true for now to not break existing functionality
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('更新体重失败:', error);
|
console.error('获取身高失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 HealthKit 获取体重(单位:千克)
|
||||||
|
*/
|
||||||
|
export async function fetchWeight(): Promise<number | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('开始保存身高到 HealthKit...', { heightInCm, unit });
|
||||||
|
|
||||||
|
if (heightInCm <= 0) {
|
||||||
|
console.error('身高值无效:', heightInCm);
|
||||||
return false;
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
export async function testOxygenSaturationData(_date: Date = dayjs().toDate()): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user