/ BalanceKit / HealthManager.swift
HealthManager.swift
1 import Foundation 2 import HealthKit 3 import OSLog 4 5 // MARK: - Cache Structure 6 struct HealthDataCache: Codable { 7 var activeCalories: Double 8 var steps: Int 9 var waterConsumed: Double 10 var lastUpdate: Date 11 var cacheDate: Date // Für welches Datum gelten diese Daten 12 13 func isValid(for date: Date, maxAge: TimeInterval = 300) -> Bool { 14 let calendar = Calendar.current 15 let isSameDay = calendar.isDate(cacheDate, inSameDayAs: date) 16 let cacheAge = Date().timeIntervalSince(lastUpdate) 17 return isSameDay && cacheAge < maxAge 18 } 19 } 20 21 class HealthManager: ObservableObject { 22 let healthStore = HKHealthStore() 23 @Published var activeCaloriesBurned: Double = 0 24 @Published var steps: Int = 0 25 @Published var waterConsumed: Double = 0 // Wasser in Millilitern (ml) 26 @Published var isAuthorized: Bool = false 27 28 // Cache Management 29 private var cachedData: [String: HealthDataCache] = [:] 30 private let cacheKey = "HealthKitDataCache" 31 private let cacheMaxAge: TimeInterval = 300 // 5 Minuten 32 33 init() { 34 loadCache() 35 36 // Im Simulator auch ohne HealthKit-Verfügbarkeit initialisieren 37 #if targetEnvironment(simulator) 38 #if DEBUG 39 Logger.healthKit.debug("Simulator-Modus aktiviert, verwende Mock-Daten") 40 #endif 41 // Im Simulator verwenden wir Standardwerte 42 isAuthorized = true 43 waterConsumed = 0.0 44 #else 45 if HKHealthStore.isHealthDataAvailable() { 46 requestAuthorization() 47 } 48 #endif 49 } 50 51 func requestAuthorization() { 52 // Definieren welche Datentypen wir lesen wollen 53 let typesToRead: Set<HKObjectType> = [ 54 HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!, 55 HKObjectType.quantityType(forIdentifier: .stepCount)!, 56 HKObjectType.quantityType(forIdentifier: .dietaryWater)! // Wasser-Tracking hinzugefügt 57 ] 58 59 // Definieren welche Datentypen wir schreiben wollen 60 let typesToWrite: Set<HKSampleType> = [ 61 HKObjectType.quantityType(forIdentifier: .dietaryEnergyConsumed)!, 62 HKObjectType.quantityType(forIdentifier: .dietaryProtein)!, 63 HKObjectType.quantityType(forIdentifier: .dietaryFatTotal)!, 64 HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)!, 65 HKObjectType.quantityType(forIdentifier: .dietaryWater)! // Wasser-Tracking hinzugefügt 66 ] 67 68 // Zugriff anfragen 69 healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { success, error in 70 DispatchQueue.main.async { 71 self.isAuthorized = success 72 if success { 73 Logger.healthKit.info("HealthKit Zugriff gewährt") 74 self.fetchTodaysData() 75 } else if let error = error { 76 Logger.healthKit.error("HealthKit Zugriff verweigert: \(error.localizedDescription)") 77 } 78 } 79 } 80 } 81 82 func fetchTodaysData() { 83 let today = Date() 84 85 // Prüfe Cache zuerst 86 if let cache = getCachedData(for: today), cache.isValid(for: today, maxAge: cacheMaxAge) { 87 #if DEBUG 88 Logger.healthKit.debug("HealthKit Daten aus Cache geladen (Alter: \(Int(Date().timeIntervalSince(cache.lastUpdate)))s)") 89 #endif 90 DispatchQueue.main.async { 91 self.activeCaloriesBurned = cache.activeCalories 92 self.steps = cache.steps 93 self.waterConsumed = cache.waterConsumed 94 } 95 return 96 } 97 98 // Andernfalls von HealthKit laden 99 #if DEBUG 100 Logger.healthKit.debug("Lade frische HealthKit Daten...") 101 #endif 102 fetchSteps() 103 fetchActiveCalories() 104 fetchWaterIntake() 105 } 106 107 // Generische Helper-Methode für HealthKit Abfragen 108 private func fetchQuantityForDate(_ date: Date, typeIdentifier: HKQuantityTypeIdentifier, unit: HKUnit, completion: @escaping (Double) -> Void) { 109 #if targetEnvironment(simulator) 110 completion(0) 111 return 112 #else 113 guard let quantityType = HKQuantityType.quantityType(forIdentifier: typeIdentifier) else { 114 completion(0) 115 return 116 } 117 118 let calendar = Calendar.current 119 let startOfDay = calendar.startOfDay(for: date) 120 guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { 121 Logger.healthKit.error("Fehler beim Berechnen des Tagesendes für \(date)") 122 completion(0) 123 return 124 } 125 126 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: endOfDay, options: .strictStartDate) 127 128 let query = HKStatisticsQuery(quantityType: quantityType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in 129 guard let result = result, let sum = result.sumQuantity() else { 130 if let error = error { 131 Logger.healthKit.error("Fehler beim Laden von \(typeIdentifier.rawValue) für \(date): \(error.localizedDescription)") 132 } 133 completion(0) 134 return 135 } 136 137 let value = sum.doubleValue(for: unit) 138 completion(value) 139 } 140 141 healthStore.execute(query) 142 #endif 143 } 144 145 // Schritte für ein bestimmtes Datum abrufen 146 func fetchStepsForDate(_ date: Date, completion: @escaping (Int) -> Void) { 147 fetchQuantityForDate(date, typeIdentifier: .stepCount, unit: HKUnit.count()) { value in 148 completion(Int(value)) 149 } 150 } 151 152 // Aktive Kalorien für ein bestimmtes Datum abrufen und cachen 153 func fetchActiveCaloriesForDate(_ date: Date, completion: @escaping (Double) -> Void) { 154 fetchQuantityForDate(date, typeIdentifier: .activeEnergyBurned, unit: HKUnit.kilocalorie()) { calories in 155 // Cache die Daten, damit sie beim nächsten Zugriff verfügbar sind 156 DispatchQueue.main.async { 157 let cacheData = HealthDataCache( 158 activeCalories: calories, 159 steps: 0, // Wird später aktualisiert, falls benötigt 160 waterConsumed: 0, // Wird später aktualisiert, falls benötigt 161 lastUpdate: Date(), 162 cacheDate: date 163 ) 164 self.updateCache(date: date, data: cacheData) 165 #if DEBUG 166 Logger.healthKit.debug("HealthKit Cache für \(date) aktualisiert: \(calories) kcal") 167 #endif 168 completion(calories) 169 } 170 } 171 } 172 173 // Schritte abrufen (für heute) 174 func fetchSteps() { 175 let stepsType = HKQuantityType.quantityType(forIdentifier: .stepCount)! 176 let now = Date() 177 let startOfDay = Calendar.current.startOfDay(for: now) 178 179 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate) 180 181 let query = HKStatisticsQuery(quantityType: stepsType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in 182 guard let result = result, let sum = result.sumQuantity() else { 183 Logger.healthKit.error("Fehler beim Laden der Schritte: \(error?.localizedDescription ?? "Unbekannter Fehler")") 184 return 185 } 186 187 let steps = sum.doubleValue(for: HKUnit.count()) 188 189 DispatchQueue.main.async { 190 self.steps = Int(steps) 191 self.updateCache(date: now) 192 } 193 } 194 195 healthStore.execute(query) 196 } 197 198 // Aktive Kalorien abrufen (für heute) 199 func fetchActiveCalories() { 200 let caloriesType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)! 201 let now = Date() 202 let startOfDay = Calendar.current.startOfDay(for: now) 203 204 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate) 205 206 let query = HKStatisticsQuery(quantityType: caloriesType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in 207 guard let result = result, let sum = result.sumQuantity() else { 208 Logger.healthKit.error("Fehler beim Laden der aktiven Kalorien: \(error?.localizedDescription ?? "Unbekannter Fehler")") 209 return 210 } 211 212 let calories = sum.doubleValue(for: HKUnit.kilocalorie()) 213 214 DispatchQueue.main.async { 215 self.activeCaloriesBurned = calories 216 self.updateCache(date: now) 217 } 218 } 219 220 healthStore.execute(query) 221 } 222 223 // Nahrungsinformationen in Health App speichern 224 func saveFoodItem(name: String, calories: Double, protein: Double, carbs: Double, fat: Double) { 225 guard isAuthorized else { 226 Logger.healthKit.warning("HealthKit nicht autorisiert") 227 return 228 } 229 230 // Metadata für den Eintrag 231 let metadata: [String: Any] = [ 232 HKMetadataKeyFoodType: name 233 ] 234 235 // Kalorien speichern 236 let caloriesType = HKQuantityType.quantityType(forIdentifier: .dietaryEnergyConsumed)! 237 let caloriesQuantity = HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories) 238 let caloriesSample = HKQuantitySample(type: caloriesType, quantity: caloriesQuantity, start: Date(), end: Date(), metadata: metadata) 239 240 // Protein speichern 241 let proteinType = HKQuantityType.quantityType(forIdentifier: .dietaryProtein)! 242 let proteinQuantity = HKQuantity(unit: HKUnit.gram(), doubleValue: protein) 243 let proteinSample = HKQuantitySample(type: proteinType, quantity: proteinQuantity, start: Date(), end: Date(), metadata: metadata) 244 245 // Kohlenhydrate speichern 246 let carbsType = HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates)! 247 let carbsQuantity = HKQuantity(unit: HKUnit.gram(), doubleValue: carbs) 248 let carbsSample = HKQuantitySample(type: carbsType, quantity: carbsQuantity, start: Date(), end: Date(), metadata: metadata) 249 250 // Fett speichern 251 let fatType = HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal)! 252 let fatQuantity = HKQuantity(unit: HKUnit.gram(), doubleValue: fat) 253 let fatSample = HKQuantitySample(type: fatType, quantity: fatQuantity, start: Date(), end: Date(), metadata: metadata) 254 255 // Alle Daten speichern 256 healthStore.save([caloriesSample, proteinSample, carbsSample, fatSample]) { success, error in 257 if let error = error { 258 Logger.healthKit.error("Fehler beim Speichern in Health: \(error.localizedDescription)") 259 } else { 260 Logger.healthKit.info("Erfolgreich in Health gespeichert: \(name)") 261 } 262 } 263 } 264 265 // Wasserkonsum für ein bestimmtes Datum abrufen 266 func fetchWaterIntakeForDate(_ date: Date, completion: @escaping (Double) -> Void) { 267 #if DEBUG 268 Logger.healthKit.debug("Wasser-Tracking - Lade Wasserkonsum für Datum: \(date)") 269 #endif 270 fetchQuantityForDate(date, typeIdentifier: .dietaryWater, unit: HKUnit.liter()) { liters in 271 let milliliters = liters * 1000 272 #if DEBUG 273 Logger.healthKit.debug("Wasser-Tracking - Geladen für \(date): \(milliliters) ml") 274 #endif 275 completion(milliliters) 276 } 277 } 278 279 // Wasserkonsum aus HealthKit abrufen (für heute) 280 func fetchWaterIntake() { 281 #if DEBUG 282 Logger.healthKit.debug("Wasser-Tracking - Lade Wasserkonsum") 283 #endif 284 285 #if targetEnvironment(simulator) 286 // Im Simulator ist waterConsumed bereits initialisiert (siehe init) 287 // Wir müssen hier nichts tun, da der Wert bereits gesetzt ist 288 #if DEBUG 289 Logger.healthKit.debug("Wasser-Tracking - Simulator-Modus, verwende vorhandenen Wert: \(self.waterConsumed)") 290 #endif 291 #else 292 let waterType = HKQuantityType.quantityType(forIdentifier: .dietaryWater)! 293 let now = Date() 294 let startOfDay = Calendar.current.startOfDay(for: now) 295 296 let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate) 297 298 let query = HKStatisticsQuery(quantityType: waterType, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, result, error in 299 if let error = error { 300 Logger.healthKit.error("Wasser-Tracking - Fehler beim Laden: \(error.localizedDescription)") 301 return 302 } 303 304 if let result = result, let sum = result.sumQuantity() { 305 let waterInLiters = sum.doubleValue(for: HKUnit.liter()) 306 let waterInML = waterInLiters * 1000 // Konvertierung in Milliliter 307 308 #if DEBUG 309 Logger.healthKit.debug("Wasser-Tracking - Geladen: \(waterInML) ml") 310 #endif 311 312 DispatchQueue.main.async { 313 self.waterConsumed = waterInML 314 self.updateCache(date: now) 315 } 316 } else { 317 #if DEBUG 318 Logger.healthKit.debug("Wasser-Tracking - Keine Daten gefunden") 319 #endif 320 // Setze Wert auf 0, um die View zu aktualisieren 321 DispatchQueue.main.async { 322 self.waterConsumed = 0 323 self.updateCache(date: now) 324 } 325 } 326 } 327 328 healthStore.execute(query) 329 #endif 330 } 331 332 // Wasser zur Health App hinzufügen 333 func addWater(milliliters: Double) { 334 #if DEBUG 335 Logger.healthKit.debug("Wasser-Tracking - Hinzufügen von \(milliliters) ml") 336 #endif 337 338 #if targetEnvironment(simulator) 339 // Im Simulator direkt den Wert erhöhen ohne HealthKit 340 #if DEBUG 341 Logger.healthKit.debug("Wasser-Tracking - Simulator-Modus, direkte Aktualisierung") 342 #endif 343 DispatchQueue.main.async { 344 self.waterConsumed += milliliters 345 self.objectWillChange.send() 346 self.updateCache(date: Date()) 347 } 348 #else 349 guard isAuthorized else { 350 Logger.healthKit.warning("Wasser-Tracking - HealthKit nicht autorisiert") 351 return 352 } 353 354 // Konvertiere Milliliter in Liter für HealthKit 355 let liters = milliliters / 1000 356 357 // Sofortige UI-Aktualisierung für besseres Nutzererlebnis 358 DispatchQueue.main.async { 359 self.waterConsumed += milliliters 360 self.objectWillChange.send() 361 self.updateCache(date: Date()) 362 } 363 364 // Metadata für den Eintrag 365 let metadata: [String: Any] = [ 366 HKMetadataKeyWasUserEntered: true 367 ] 368 369 // Wasser speichern 370 let waterType = HKQuantityType.quantityType(forIdentifier: .dietaryWater)! 371 let waterQuantity = HKQuantity(unit: HKUnit.liter(), doubleValue: liters) 372 let waterSample = HKQuantitySample(type: waterType, quantity: waterQuantity, start: Date(), end: Date(), metadata: metadata) 373 374 // Speichern des Wassereintrags 375 healthStore.save(waterSample) { success, error in 376 if let error = error { 377 Logger.healthKit.error("Wasser-Tracking - Fehler beim Speichern: \(error.localizedDescription)") 378 // Bei Fehler den Wert zurücksetzen 379 DispatchQueue.main.async { 380 self.waterConsumed -= milliliters 381 self.objectWillChange.send() 382 self.updateCache(date: Date()) 383 } 384 } else { 385 #if DEBUG 386 Logger.healthKit.debug("Wasser-Tracking - Erfolgreich in HealthKit gespeichert: \(milliliters) ml") 387 #endif 388 } 389 } 390 #endif 391 } 392 393 // MARK: - Cache Management 394 private func getCacheKey(for date: Date) -> String { 395 let formatter = DateFormatter() 396 formatter.dateFormat = "yyyy-MM-dd" 397 return formatter.string(from: date) 398 } 399 400 private func getCachedData(for date: Date) -> HealthDataCache? { 401 let key = getCacheKey(for: date) 402 return cachedData[key] 403 } 404 405 private func updateCache(date: Date) { 406 let key = getCacheKey(for: date) 407 let cache = HealthDataCache( 408 activeCalories: activeCaloriesBurned, 409 steps: steps, 410 waterConsumed: waterConsumed, 411 lastUpdate: Date(), 412 cacheDate: date 413 ) 414 cachedData[key] = cache 415 saveCache() 416 } 417 418 // Überladene Version zum direkten Setzen von Cache-Daten 419 private func updateCache(date: Date, data: HealthDataCache) { 420 let key = getCacheKey(for: date) 421 cachedData[key] = data 422 saveCache() 423 // Trigger UI update 424 objectWillChange.send() 425 } 426 427 private func saveCache() { 428 do { 429 let encoder = JSONEncoder() 430 let data = try encoder.encode(cachedData) 431 UserDefaults.standard.set(data, forKey: cacheKey) 432 #if DEBUG 433 Logger.healthKit.debug("HealthKit Cache gespeichert (\(self.cachedData.count) Tage)") 434 #endif 435 } catch { 436 Logger.healthKit.error("❌ Fehler beim Speichern des HealthKit Cache: \(error)") 437 } 438 } 439 440 private func loadCache() { 441 guard let data = UserDefaults.standard.data(forKey: cacheKey) else { 442 #if DEBUG 443 Logger.healthKit.info("ℹ️ Kein HealthKit Cache gefunden") 444 #endif 445 return 446 } 447 448 do { 449 let decoder = JSONDecoder() 450 cachedData = try decoder.decode([String: HealthDataCache].self, from: data) 451 452 // Bereinige alten Cache (älter als 7 Tage) 453 guard let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else { 454 Logger.healthKit.error("Fehler beim Berechnen des Cache-Bereinigungsdatums") 455 return 456 } 457 cachedData = cachedData.filter { _, cache in 458 cache.cacheDate >= sevenDaysAgo 459 } 460 461 #if DEBUG 462 Logger.healthKit.debug("✅ HealthKit Cache geladen (\(self.cachedData.count) Tage)") 463 #endif 464 } catch { 465 Logger.healthKit.error("❌ Fehler beim Laden des HealthKit Cache: \(error)") 466 cachedData = [:] 467 } 468 } 469 470 // Öffentliche Funktion zum Abrufen gecachter Daten für historische Ansichten 471 func getCachedHealthData(for date: Date) -> (activeCalories: Double, steps: Int, waterConsumed: Double)? { 472 if let cache = getCachedData(for: date), cache.isValid(for: date, maxAge: .infinity) { 473 return (cache.activeCalories, cache.steps, cache.waterConsumed) 474 } 475 return nil 476 } 477 }