feat(healthkit): 实现HealthKit与服务端的双向数据同步,包括身高、体重和出生日期的获取与保存

This commit is contained in:
richarjiang
2025-11-19 15:42:50 +08:00
parent dc205ad56e
commit 6039d0a778
8 changed files with 1029 additions and 8 deletions

View File

@@ -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);
}, []) }, [])

View File

@@ -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(() => {
// 如果已经初始化过,则跳过(确保只初始化一次) // 如果已经初始化过,则跳过(确保只初始化一次)

View File

@@ -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);

View File

@@ -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

View File

@@ -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
View 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();
}

View File

@@ -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') {
// 处理运动提醒 // 处理运动提醒

View File

@@ -1061,18 +1061,170 @@ 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;
}
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; 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> {
console.log('=== 开始测试血氧饱和度数据获取 ==='); console.log('=== 开始测试血氧饱和度数据获取 ===');