diff --git a/app/_layout.tsx b/app/_layout.tsx index e871cd8..aa2bace 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -9,7 +9,6 @@ import 'react-native-reanimated'; import PrivacyConsentModal from '@/components/PrivacyConsentModal'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useQuickActions } from '@/hooks/useQuickActions'; -import { clearAiCoachSessionCache } from '@/services/aiCoachSession'; import { hrvMonitorService } from '@/services/hrvMonitor'; import { notificationService } from '@/services/notifications'; import { setupQuickActions } from '@/services/quickActions'; @@ -137,10 +136,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { await setupQuickActions(); logger.info('✅ 快捷动作初始化完成'); - // 4. 清空 AI 教练会话缓存(轻量操作) - clearAiCoachSessionCache(); - logger.info('✅ AI 教练缓存清理完成'); - // 5. 初始化喝水记录 Bridge initializeWaterRecordBridge(); logger.info('✅ 喝水记录 Bridge 初始化完成'); diff --git a/app/food-library.tsx b/app/food-library.tsx index faa4293..6f9f01b 100644 --- a/app/food-library.tsx +++ b/app/food-library.tsx @@ -10,6 +10,7 @@ import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords' import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi'; import { fetchDailyNutritionData } from '@/store/nutritionSlice'; import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food'; +import { saveNutritionToHealthKit } from '@/utils/health'; import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -178,7 +179,27 @@ export default function FoodLibraryScreen() { imageUrl: item.food.imageUrl, }; + // 先保存到后端 await addDietRecord(dietRecordData); + + // 然后尝试同步到 HealthKit(非阻塞) + // 提取蛋白质、脂肪和碳水化合物数据 + const { proteinGrams, fatGrams, carbohydrateGrams, mealTime } = dietRecordData; + + if (proteinGrams !== undefined || fatGrams !== undefined || carbohydrateGrams !== undefined) { + // 使用 catch 确保 HealthKit 同步失败不影响后端记录 + saveNutritionToHealthKit( + { + proteinGrams: proteinGrams || undefined, + fatGrams: fatGrams || undefined, + carbohydrateGrams: carbohydrateGrams || undefined + }, + mealTime + ).catch(error => { + // HealthKit 同步失败只记录日志,不影响用户体验 + console.error('HealthKit 营养数据同步失败(不影响记录):', error); + }); + } } // 记录成功后,刷新当天的营养数据 diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index f994b34..200d4e8 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -78,6 +78,19 @@ RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +// Nutrition Data Methods +RCT_EXTERN_METHOD(saveProteinToHealthKit:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(saveFatToHealthKit:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(saveCarbohydratesToHealthKit:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + // Workout Data Methods RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index a058679..7861ad3 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -88,11 +88,23 @@ class HealthKitManager: RCTEventEmitter { static var dietaryWater: HKQuantityType? { return HKObjectType.quantityType(forIdentifier: .dietaryWater) } + static var dietaryProtein: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .dietaryProtein) + } + static var dietaryFatTotal: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .dietaryFatTotal) + } + static var dietaryCarbohydrates: HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates) + } static var all: Set { var types: Set = [] if let bodyMass = bodyMass { types.insert(bodyMass) } if let dietaryWater = dietaryWater { types.insert(dietaryWater) } + if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) } + if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) } + if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) } return types } } @@ -1669,6 +1681,194 @@ class HealthKitManager: RCTEventEmitter { healthStore.execute(query) } + // MARK: - Nutrition Data Methods + + @objc + func saveProteinToHealthKit( + _ 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 + } + + // Parse parameters + guard let amount = options["amount"] as? Double else { + rejecter("INVALID_PARAMETERS", "Amount is required", nil) + return + } + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + guard let proteinType = WriteTypes.dietaryProtein else { + rejecter("TYPE_NOT_AVAILABLE", "Protein type is not available", nil) + return + } + + // Create quantity sample (protein in grams) + let quantity = HKQuantity(unit: HKUnit.gram(), doubleValue: amount) + let sample = HKQuantitySample( + type: proteinType, + quantity: quantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // Save to HealthKit + healthStore.save(sample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save protein: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "amount": amount, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save protein", nil) + } + } + } + } + + @objc + func saveFatToHealthKit( + _ 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 + } + + // Parse parameters + guard let amount = options["amount"] as? Double else { + rejecter("INVALID_PARAMETERS", "Amount is required", nil) + return + } + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + guard let fatType = WriteTypes.dietaryFatTotal else { + rejecter("TYPE_NOT_AVAILABLE", "Fat type is not available", nil) + return + } + + // Create quantity sample (fat in grams) + let quantity = HKQuantity(unit: HKUnit.gram(), doubleValue: amount) + let sample = HKQuantitySample( + type: fatType, + quantity: quantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // Save to HealthKit + healthStore.save(sample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save fat: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "amount": amount, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save fat", nil) + } + } + } + } + + @objc + func saveCarbohydratesToHealthKit( + _ 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 + } + + // Parse parameters + guard let amount = options["amount"] as? Double else { + rejecter("INVALID_PARAMETERS", "Amount is required", nil) + return + } + + let recordedAt: Date + if let recordedAtString = options["recordedAt"] as? String, + let date = parseDate(from: recordedAtString) { + recordedAt = date + } else { + recordedAt = Date() + } + + guard let carbohydratesType = WriteTypes.dietaryCarbohydrates else { + rejecter("TYPE_NOT_AVAILABLE", "Carbohydrates type is not available", nil) + return + } + + // Create quantity sample (carbohydrates in grams) + let quantity = HKQuantity(unit: HKUnit.gram(), doubleValue: amount) + let sample = HKQuantitySample( + type: carbohydratesType, + quantity: quantity, + start: recordedAt, + end: recordedAt, + metadata: nil + ) + + // Save to HealthKit + healthStore.save(sample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save carbohydrates: \(error.localizedDescription)", error) + return + } + + if success { + let result: [String: Any] = [ + "success": true, + "amount": amount, + "recordedAt": self?.dateToISOString(recordedAt) ?? "" + ] + resolver(result) + } else { + rejecter("SAVE_FAILED", "Failed to save carbohydrates", nil) + } + } + } + } + // MARK: - Workout Data Methods @objc diff --git a/utils/health.ts b/utils/health.ts index 050f719..1a6ff67 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -1139,6 +1139,114 @@ export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: st } } +// 添加蛋白质记录到 HealthKit +export async function saveProteinToHealthKit(grams: number, recordedAt?: string): Promise { + try { + console.log('开始保存蛋白质记录到HealthKit...', { grams, recordedAt }); + + const options = { + amount: grams, + recordedAt: recordedAt || new Date().toISOString() + }; + + const result = await HealthKitManager.saveProteinToHealthKit(options); + + if (result && result.success) { + console.log('蛋白质记录保存成功:', result); + return true; + } else { + console.error('蛋白质记录保存失败:', result); + return false; + } + } catch (error) { + console.error('添加蛋白质记录到 HealthKit 失败:', error); + return false; + } +} + +// 添加脂肪记录到 HealthKit +export async function saveFatToHealthKit(grams: number, recordedAt?: string): Promise { + try { + console.log('开始保存脂肪记录到HealthKit...', { grams, recordedAt }); + + const options = { + amount: grams, + recordedAt: recordedAt || new Date().toISOString() + }; + + const result = await HealthKitManager.saveFatToHealthKit(options); + + if (result && result.success) { + console.log('脂肪记录保存成功:', result); + return true; + } else { + console.error('脂肪记录保存失败:', result); + return false; + } + } catch (error) { + console.error('添加脂肪记录到 HealthKit 失败:', error); + return false; + } +} + +// 添加碳水化合物记录到 HealthKit +export async function saveCarbohydratesToHealthKit(grams: number, recordedAt?: string): Promise { + try { + console.log('开始保存碳水化合物记录到HealthKit...', { grams, recordedAt }); + + const options = { + amount: grams, + recordedAt: recordedAt || new Date().toISOString() + }; + + const result = await HealthKitManager.saveCarbohydratesToHealthKit(options); + + if (result && result.success) { + console.log('碳水化合物记录保存成功:', result); + return true; + } else { + console.error('碳水化合物记录保存失败:', result); + return false; + } + } catch (error) { + console.error('添加碳水化合物记录到 HealthKit 失败:', error); + return false; + } +} + +// 批量保存营养数据到 HealthKit(蛋白质、脂肪和碳水化合物) +export async function saveNutritionToHealthKit( + nutrition: { proteinGrams?: number; fatGrams?: number; carbohydrateGrams?: number }, + recordedAt?: string +): Promise<{ proteinSaved: boolean; fatSaved: boolean; carbohydrateSaved: boolean }> { + try { + console.log('开始批量保存营养数据到HealthKit...', { nutrition, recordedAt }); + + const results = await Promise.allSettled([ + nutrition.proteinGrams && nutrition.proteinGrams > 0 + ? saveProteinToHealthKit(nutrition.proteinGrams, recordedAt) + : Promise.resolve(false), + nutrition.fatGrams && nutrition.fatGrams > 0 + ? saveFatToHealthKit(nutrition.fatGrams, recordedAt) + : Promise.resolve(false), + nutrition.carbohydrateGrams && nutrition.carbohydrateGrams > 0 + ? saveCarbohydratesToHealthKit(nutrition.carbohydrateGrams, recordedAt) + : Promise.resolve(false), + ]); + + const proteinSaved = results[0].status === 'fulfilled' ? results[0].value : false; + const fatSaved = results[1].status === 'fulfilled' ? results[1].value : false; + const carbohydrateSaved = results[2].status === 'fulfilled' ? results[2].value : false; + + console.log('营养数据批量保存结果:', { proteinSaved, fatSaved, carbohydrateSaved }); + + return { proteinSaved, fatSaved, carbohydrateSaved }; + } catch (error) { + console.error('批量保存营养数据到 HealthKit 失败:', error); + return { proteinSaved: false, fatSaved: false, carbohydrateSaved: false }; + } +} + // 获取 HealthKit 中的饮水记录 export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise { try { diff --git a/utils/healthKit.ts b/utils/healthKit.ts index 6b2dbbd..3d088ac 100644 --- a/utils/healthKit.ts +++ b/utils/healthKit.ts @@ -43,6 +43,17 @@ export interface SleepDataResult { endDate: string; } +export interface NutritionSaveOptions { + amount: number; // Amount in grams + recordedAt?: string; // ISO8601 format, defaults to now +} + +export interface NutritionSaveResult { + success: boolean; + amount: number; + recordedAt: string; +} + export interface HealthKitManagerInterface { /** * Request authorization to access HealthKit data @@ -61,6 +72,24 @@ export interface HealthKitManagerInterface { * @param options Query options including date range and limit */ getSleepData(options?: SleepDataOptions): Promise; + + /** + * Save protein intake to HealthKit + * @param options Nutrition save options including amount in grams and optional timestamp + */ + saveProteinToHealthKit(options: NutritionSaveOptions): Promise; + + /** + * Save fat intake to HealthKit + * @param options Nutrition save options including amount in grams and optional timestamp + */ + saveFatToHealthKit(options: NutritionSaveOptions): Promise; + + /** + * Save carbohydrates intake to HealthKit + * @param options Nutrition save options including amount in grams and optional timestamp + */ + saveCarbohydratesToHealthKit(options: NutritionSaveOptions): Promise; } // Native module interface diff --git a/utils/notificationHelpers.ts b/utils/notificationHelpers.ts index 9adc7be..5b1ecc1 100644 --- a/utils/notificationHelpers.ts +++ b/utils/notificationHelpers.ts @@ -187,49 +187,6 @@ export class NutritionNotificationHelpers { } } - /** - * 发送午餐记录提醒 - */ - static async sendLunchReminder(userName: string) { - const coachUrl = buildCoachDeepLink({ - action: 'diet', - subAction: 'card', - meal: 'lunch' - }); - - return notificationService.sendImmediateNotification({ - title: '午餐记录提醒', - body: `${userName},记得记录今天的午餐情况哦!`, - data: { - type: 'lunch_reminder', - meal: '午餐', - url: coachUrl - }, - sound: true, - priority: 'normal', - }); - } - - /** - * 取消午餐提醒 - */ - static async cancelLunchReminder(): Promise { - try { - const notifications = await notificationService.getAllScheduledNotifications(); - - for (const notification of notifications) { - if (notification.content.data?.type === 'lunch_reminder' && - notification.content.data?.isDailyReminder === true) { - await notificationService.cancelNotification(notification.identifier); - console.log('已取消午餐提醒:', notification.identifier); - } - } - } catch (error) { - console.error('取消午餐提醒失败:', error); - throw error; - } - } - /** * 安排每日晚餐提醒 * @param userName 用户名