diff --git a/HealthBridge/App/HealthBridgeApp.swift b/HealthBridge/App/HealthBridgeApp.swift new file mode 100644 index 0000000..7849c66 --- /dev/null +++ b/HealthBridge/App/HealthBridgeApp.swift @@ -0,0 +1,91 @@ +import SwiftUI +import HealthKit +import BackgroundTasks + +@main +struct HealthBridgeApp: App { + @StateObject private var appState = AppState() + @StateObject private var healthKitManager = HealthKitManager.shared + @StateObject private var syncCoordinator = SyncCoordinator.shared + + init() { + registerBackgroundTasks() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + .environmentObject(healthKitManager) + .environmentObject(syncCoordinator) + .onAppear { + Task { + await requestHealthKitAuthorization() + } + } + } + } + + private func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.healthbridge.sync", + using: nil + ) { task in + guard let bgTask = task as? BGAppRefreshTask else { return } + handleBackgroundSync(task: bgTask) + } + } + + private func handleBackgroundSync(task: BGAppRefreshTask) { + scheduleNextBackgroundSync() + + let syncTask = Task { + do { + try await syncCoordinator.performSync() + task.setTaskCompleted(success: true) + } catch { + task.setTaskCompleted(success: false) + } + } + + task.expirationHandler = { + syncTask.cancel() + } + } + + private func scheduleNextBackgroundSync() { + let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync") + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + print("Failed to schedule background sync: \(error)") + } + } + + private func requestHealthKitAuthorization() async { + do { + try await healthKitManager.requestAuthorization() + } catch { + print("HealthKit authorization failed: \(error)") + } + } +} + +// MARK: - App State +@MainActor +class AppState: ObservableObject { + @Published var selectedTab: Tab = .dashboard + @Published var showingConflictDetail: Conflict? + @Published var isLoading = false + @Published var lastSyncDate: Date? + @Published var pendingConflicts: [Conflict] = [] + + enum Tab { + case dashboard + case conflicts + case rules + case sources + } +} diff --git a/HealthBridge/Models/Conflict.swift b/HealthBridge/Models/Conflict.swift new file mode 100644 index 0000000..c9beec9 --- /dev/null +++ b/HealthBridge/Models/Conflict.swift @@ -0,0 +1,315 @@ +import Foundation + +// MARK: - Conflict +struct Conflict: Identifiable, Codable { + let id: UUID + let dataType: HealthDataType + let timeWindow: TimeWindow + var readings: [SourceReading] + var status: ConflictStatus + var resolution: ConflictResolution? + var appliedStrategy: MergeStrategy? + let detectedAt: Date + var resolvedAt: Date? + + init( + id: UUID = UUID(), + dataType: HealthDataType, + timeWindow: TimeWindow, + readings: [SourceReading], + status: ConflictStatus = .pending, + resolution: ConflictResolution? = nil, + appliedStrategy: MergeStrategy? = nil, + detectedAt: Date = Date(), + resolvedAt: Date? = nil + ) { + self.id = id + self.dataType = dataType + self.timeWindow = timeWindow + self.readings = readings + self.status = status + self.resolution = resolution + self.appliedStrategy = appliedStrategy + self.detectedAt = detectedAt + self.resolvedAt = resolvedAt + } + + var valueDifference: Double { + guard readings.count >= 2 else { return 0 } + let values = readings.map { $0.value } + return (values.max() ?? 0) - (values.min() ?? 0) + } + + var percentageDifference: Double { + guard readings.count >= 2 else { return 0 } + let values = readings.map { $0.value } + guard let min = values.min(), min > 0 else { return 0 } + guard let max = values.max() else { return 0 } + return ((max - min) / min) * 100 + } + + var severity: ConflictSeverity { + let pctDiff = percentageDifference + if pctDiff < 5 { return .minor } + if pctDiff < 20 { return .moderate } + if pctDiff < 50 { return .significant } + return .major + } + + var highestValueReading: SourceReading? { + readings.max(by: { $0.value < $1.value }) + } + + var lowestValueReading: SourceReading? { + readings.min(by: { $0.value < $1.value }) + } + + var primarySourceReading: SourceReading? { + readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority }) + } +} + +// MARK: - Conflict Status +enum ConflictStatus: String, Codable { + case pending = "pending" + case resolved = "resolved" + case manualReview = "manual_review" + case ignored = "ignored" + + var displayName: String { + switch self { + case .pending: return "Offen" + case .resolved: return "Gelöst" + case .manualReview: return "Manuelle Prüfung" + case .ignored: return "Ignoriert" + } + } + + var icon: String { + switch self { + case .pending: return "clock.fill" + case .resolved: return "checkmark.circle.fill" + case .manualReview: return "hand.raised.fill" + case .ignored: return "eye.slash.fill" + } + } +} + +// MARK: - Conflict Severity +enum ConflictSeverity: String, Codable { + case minor = "minor" + case moderate = "moderate" + case significant = "significant" + case major = "major" + + var displayName: String { + switch self { + case .minor: return "Gering" + case .moderate: return "Moderat" + case .significant: return "Erheblich" + case .major: return "Gross" + } + } + + var color: String { + switch self { + case .minor: return "green" + case .moderate: return "yellow" + case .significant: return "orange" + case .major: return "red" + } + } +} + +// MARK: - Conflict Resolution +struct ConflictResolution: Codable { + let resolvedValue: Double + let secondaryResolvedValue: Double? // For blood pressure + let winningSourceId: String + let strategy: MergeStrategy + let isManual: Bool + let resolvedAt: Date + let notes: String? + + init( + resolvedValue: Double, + secondaryResolvedValue: Double? = nil, + winningSourceId: String, + strategy: MergeStrategy, + isManual: Bool = false, + resolvedAt: Date = Date(), + notes: String? = nil + ) { + self.resolvedValue = resolvedValue + self.secondaryResolvedValue = secondaryResolvedValue + self.winningSourceId = winningSourceId + self.strategy = strategy + self.isManual = isManual + self.resolvedAt = resolvedAt + self.notes = notes + } +} + +// MARK: - Merge Strategy +enum MergeStrategy: String, Codable, CaseIterable, Identifiable { + case exclusive = "exclusive" + case priority = "priority" + case higherWins = "higher_wins" + case lowerWins = "lower_wins" + case average = "average" + case coverage = "coverage" + case coverageThenHigher = "coverage_then_higher" + case manual = "manual" + case mostRecent = "most_recent" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .exclusive: return "Exklusiv" + case .priority: return "Priorität" + case .higherWins: return "Höherer Wert" + case .lowerWins: return "Niedrigerer Wert" + case .average: return "Durchschnitt" + case .coverage: return "Abdeckung" + case .coverageThenHigher: return "Abdeckung + Höher" + case .manual: return "Manuell" + case .mostRecent: return "Neuester" + } + } + + var description: String { + switch self { + case .exclusive: + return "Nur eine Quelle kann diesen Datentyp liefern" + case .priority: + return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen" + case .higherWins: + return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)" + case .lowerWins: + return "Der kleinere Wert wird verwendet" + case .average: + return "Durchschnitt aller Quellen" + case .coverage: + return "Quelle mit Daten für dieses Zeitfenster" + case .coverageThenHigher: + return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt" + case .manual: + return "Benutzer entscheidet bei jedem Konflikt" + case .mostRecent: + return "Zuletzt erfasster Wert" + } + } + + var icon: String { + switch self { + case .exclusive: return "1.circle.fill" + case .priority: return "list.number" + case .higherWins: return "arrow.up.circle.fill" + case .lowerWins: return "arrow.down.circle.fill" + case .average: return "divide.circle.fill" + case .coverage: return "square.fill.on.square.fill" + case .coverageThenHigher: return "square.stack.3d.up.fill" + case .manual: return "hand.raised.fill" + case .mostRecent: return "clock.arrow.circlepath" + } + } +} + +// MARK: - Merge Rule +struct MergeRule: Identifiable, Codable { + let id: UUID + let dataType: HealthDataType + var strategy: MergeStrategy + var primarySourceId: String? + var fallbackSourceId: String? + var sourcePriorities: [String: Int] + var autoApply: Bool + var thresholdForManualReview: Double? // Percentage difference threshold + + init( + id: UUID = UUID(), + dataType: HealthDataType, + strategy: MergeStrategy, + primarySourceId: String? = nil, + fallbackSourceId: String? = nil, + sourcePriorities: [String: Int] = [:], + autoApply: Bool = true, + thresholdForManualReview: Double? = nil + ) { + self.id = id + self.dataType = dataType + self.strategy = strategy + self.primarySourceId = primarySourceId + self.fallbackSourceId = fallbackSourceId + self.sourcePriorities = sourcePriorities + self.autoApply = autoApply + self.thresholdForManualReview = thresholdForManualReview + } + + static func defaultRule(for dataType: HealthDataType) -> MergeRule { + switch dataType { + case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen, + .heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate: + return MergeRule(dataType: dataType, strategy: .exclusive) + case .floorsClimbed: + return MergeRule(dataType: dataType, strategy: .exclusive) + case .steps, .distance, .activeEnergy: + return MergeRule(dataType: dataType, strategy: .coverageThenHigher) + case .sleep: + return MergeRule(dataType: dataType, strategy: .priority) + } + } +} + +// MARK: - Sync Record +struct SyncRecord: Identifiable, Codable { + let id: UUID + let dataType: HealthDataType + let timeWindow: TimeWindow + var readings: [SourceReading] + var mergedValue: Double? + var secondaryMergedValue: Double? // For blood pressure + var strategy: MergeStrategy + var status: SyncStatus + var hasConflict: Bool + var conflictId: UUID? + let createdAt: Date + var processedAt: Date? + + enum SyncStatus: String, Codable { + case pending = "pending" + case processing = "processing" + case completed = "completed" + case failed = "failed" + case requiresManualReview = "requires_manual" + } + + init( + id: UUID = UUID(), + dataType: HealthDataType, + timeWindow: TimeWindow, + readings: [SourceReading], + mergedValue: Double? = nil, + secondaryMergedValue: Double? = nil, + strategy: MergeStrategy = .priority, + status: SyncStatus = .pending, + hasConflict: Bool = false, + conflictId: UUID? = nil, + createdAt: Date = Date(), + processedAt: Date? = nil + ) { + self.id = id + self.dataType = dataType + self.timeWindow = timeWindow + self.readings = readings + self.mergedValue = mergedValue + self.secondaryMergedValue = secondaryMergedValue + self.strategy = strategy + self.status = status + self.hasConflict = hasConflict + self.conflictId = conflictId + self.createdAt = createdAt + self.processedAt = processedAt + } +} diff --git a/HealthBridge/Models/HealthDataTypes.swift b/HealthBridge/Models/HealthDataTypes.swift new file mode 100644 index 0000000..b6414ec --- /dev/null +++ b/HealthBridge/Models/HealthDataTypes.swift @@ -0,0 +1,269 @@ +import Foundation +import HealthKit + +// MARK: - Health Data Type +enum HealthDataType: String, CaseIterable, Codable, Identifiable { + case steps = "steps" + case heartRate = "heart_rate" + case bloodPressureSystolic = "blood_pressure_systolic" + case bloodPressureDiastolic = "blood_pressure_diastolic" + case bloodOxygen = "blood_oxygen" + case sleep = "sleep" + case distance = "distance" + case floorsClimbed = "floors_climbed" + case activeEnergy = "active_energy" + case restingHeartRate = "resting_heart_rate" + case heartRateVariability = "hrv" + case respiratoryRate = "respiratory_rate" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .steps: return "Schritte" + case .heartRate: return "Herzfrequenz" + case .bloodPressureSystolic: return "Blutdruck (Systolisch)" + case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)" + case .bloodOxygen: return "Blutsauerstoff (SpO2)" + case .sleep: return "Schlaf" + case .distance: return "Distanz" + case .floorsClimbed: return "Stockwerke" + case .activeEnergy: return "Aktive Energie" + case .restingHeartRate: return "Ruhepuls" + case .heartRateVariability: return "HRV" + case .respiratoryRate: return "Atemfrequenz" + } + } + + var icon: String { + switch self { + case .steps: return "figure.walk" + case .heartRate, .restingHeartRate: return "heart.fill" + case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill" + case .bloodOxygen: return "lungs.fill" + case .sleep: return "bed.double.fill" + case .distance: return "map.fill" + case .floorsClimbed: return "stairs" + case .activeEnergy: return "flame.fill" + case .heartRateVariability: return "waveform.path.ecg" + case .respiratoryRate: return "wind" + } + } + + var unit: String { + switch self { + case .steps: return "Schritte" + case .heartRate, .restingHeartRate: return "bpm" + case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg" + case .bloodOxygen: return "%" + case .sleep: return "h" + case .distance: return "km" + case .floorsClimbed: return "Stockwerke" + case .activeEnergy: return "kcal" + case .heartRateVariability: return "ms" + case .respiratoryRate: return "/min" + } + } + + var hkQuantityType: HKQuantityType? { + switch self { + case .steps: + return HKQuantityType.quantityType(forIdentifier: .stepCount) + case .heartRate: + return HKQuantityType.quantityType(forIdentifier: .heartRate) + case .bloodPressureSystolic: + return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic) + case .bloodPressureDiastolic: + return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) + case .bloodOxygen: + return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation) + case .distance: + return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning) + case .floorsClimbed: + return HKQuantityType.quantityType(forIdentifier: .flightsClimbed) + case .activeEnergy: + return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) + case .restingHeartRate: + return HKQuantityType.quantityType(forIdentifier: .restingHeartRate) + case .heartRateVariability: + return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) + case .respiratoryRate: + return HKQuantityType.quantityType(forIdentifier: .respiratoryRate) + case .sleep: + return nil // Sleep uses category type + } + } + + var hkCategoryType: HKCategoryType? { + switch self { + case .sleep: + return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) + default: + return nil + } + } + + var hkUnit: HKUnit { + switch self { + case .steps, .floorsClimbed: + return .count() + case .heartRate, .restingHeartRate, .respiratoryRate: + return HKUnit.count().unitDivided(by: .minute()) + case .bloodPressureSystolic, .bloodPressureDiastolic: + return .millimeterOfMercury() + case .bloodOxygen: + return .percent() + case .sleep: + return .hour() + case .distance: + return .meterUnit(with: .kilo) + case .activeEnergy: + return .kilocalorie() + case .heartRateVariability: + return .secondUnit(with: .milli) + } + } + + /// Default primary source for this data type + var defaultPrimarySource: SourceCategory { + switch self { + case .floorsClimbed: + return .iPhone + case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic, + .bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate, + .heartRateVariability, .respiratoryRate: + return .watch + } + } + + /// Whether this data type typically has only one source + var isExclusive: Bool { + switch self { + case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen, + .heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep: + return true + default: + return false + } + } +} + +// MARK: - Source Category +enum SourceCategory: String, Codable, CaseIterable { + case iPhone = "iphone" + case watch = "watch" + case thirdPartyWatch = "third_party_watch" + case thirdPartyApp = "third_party_app" + case healthBridge = "health_bridge" + case unknown = "unknown" + + var displayName: String { + switch self { + case .iPhone: return "iPhone" + case .watch: return "Apple Watch" + case .thirdPartyWatch: return "Drittanbieter-Watch" + case .thirdPartyApp: return "Drittanbieter-App" + case .healthBridge: return "HealthBridge" + case .unknown: return "Unbekannt" + } + } + + var icon: String { + switch self { + case .iPhone: return "iphone" + case .watch: return "applewatch" + case .thirdPartyWatch: return "applewatch.side.right" + case .thirdPartyApp: return "app.badge" + case .healthBridge: return "arrow.triangle.2.circlepath" + case .unknown: return "questionmark.circle" + } + } + + var priority: Int { + switch self { + case .healthBridge: return 100 + case .watch: return 80 + case .thirdPartyWatch: return 70 + case .iPhone: return 50 + case .thirdPartyApp: return 30 + case .unknown: return 0 + } + } +} + +// MARK: - Data Quality +enum DataQuality: String, Codable { + case complete = "complete" + case partial = "partial" + case missing = "missing" + case invalid = "invalid" + + var icon: String { + switch self { + case .complete: return "checkmark.circle.fill" + case .partial: return "circle.lefthalf.filled" + case .missing: return "circle.dashed" + case .invalid: return "xmark.circle.fill" + } + } + + var color: String { + switch self { + case .complete: return "green" + case .partial: return "yellow" + case .missing: return "gray" + case .invalid: return "red" + } + } +} + +// MARK: - Time Window +struct TimeWindow: Codable, Hashable, Identifiable { + let start: Date + let end: Date + + var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" } + + var interval: DateInterval { + DateInterval(start: start, end: end) + } + + var duration: TimeInterval { + end.timeIntervalSince(start) + } + + var formattedRange: String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return "\(formatter.string(from: start)) - \(formatter.string(from: end))" + } + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: start) + } + + static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + var windows: [TimeWindow] = [] + var current = startOfDay + + while current < endOfDay { + let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)! + windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay))) + current = windowEnd + } + + return windows + } + + static func hourlyWindows(for date: Date) -> [TimeWindow] { + windows(for: date, intervalMinutes: 60) + } +} diff --git a/HealthBridge/Models/Source.swift b/HealthBridge/Models/Source.swift new file mode 100644 index 0000000..ea6348b --- /dev/null +++ b/HealthBridge/Models/Source.swift @@ -0,0 +1,183 @@ +import Foundation +import HealthKit + +// MARK: - Health Source +struct HealthSource: Identifiable, Codable, Hashable { + let id: String + let bundleIdentifier: String + let name: String + let category: SourceCategory + var supportedDataTypes: Set + var lastActivityDate: Date? + var userPriorities: [HealthDataType: Int] + var isEnabled: Bool + + init( + id: String = UUID().uuidString, + bundleIdentifier: String, + name: String, + category: SourceCategory, + supportedDataTypes: Set = [], + lastActivityDate: Date? = nil, + userPriorities: [HealthDataType: Int] = [:], + isEnabled: Bool = true + ) { + self.id = id + self.bundleIdentifier = bundleIdentifier + self.name = name + self.category = category + self.supportedDataTypes = supportedDataTypes + self.lastActivityDate = lastActivityDate + self.userPriorities = userPriorities + self.isEnabled = isEnabled + } + + var displayName: String { + if name.isEmpty { + return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier + } + return name + } + + var isHealthBridge: Bool { + bundleIdentifier == HealthBridgeConstants.bundleIdentifier + } + + func priority(for dataType: HealthDataType) -> Int { + userPriorities[dataType] ?? category.priority + } + + static func from(hkSource: HKSource) -> HealthSource { + let category = classifySource(bundleId: hkSource.bundleIdentifier) + return HealthSource( + id: hkSource.bundleIdentifier, + bundleIdentifier: hkSource.bundleIdentifier, + name: hkSource.name, + category: category + ) + } + + private static func classifySource(bundleId: String) -> SourceCategory { + let lowercased = bundleId.lowercased() + + if lowercased.contains("healthbridge") { + return .healthBridge + } else if lowercased.contains("apple.health") { + return .iPhone + } else if lowercased.contains("watch") || lowercased.contains("applewatch") { + return .watch + } else if lowercased.contains("huawei") || lowercased.contains("samsung") || + lowercased.contains("fitbit") || lowercased.contains("garmin") || + lowercased.contains("polar") || lowercased.contains("withings") { + return .thirdPartyWatch + } else { + return .thirdPartyApp + } + } +} + +// MARK: - Source Reading +struct SourceReading: Identifiable, Codable { + let id: UUID + let sourceId: String + let sourceName: String + let sourceCategory: SourceCategory + let value: Double + let secondaryValue: Double? // For blood pressure (diastolic) + let timestamp: Date + let originalRecordId: String? + let quality: DataQuality + + init( + id: UUID = UUID(), + sourceId: String, + sourceName: String, + sourceCategory: SourceCategory, + value: Double, + secondaryValue: Double? = nil, + timestamp: Date, + originalRecordId: String? = nil, + quality: DataQuality = .complete + ) { + self.id = id + self.sourceId = sourceId + self.sourceName = sourceName + self.sourceCategory = sourceCategory + self.value = value + self.secondaryValue = secondaryValue + self.timestamp = timestamp + self.originalRecordId = originalRecordId + self.quality = quality + } + + var formattedValue: String { + if value == floor(value) { + return String(format: "%.0f", value) + } + return String(format: "%.1f", value) + } +} + +// MARK: - Source Health Status +struct SourceHealthStatus: Identifiable { + let id: String + let source: HealthSource + let lastSync: Date? + let recordCount: Int + let dataGaps: [TimeWindow] + let overallQuality: DataQuality + + var syncStatus: SyncStatus { + guard let lastSync = lastSync else { + return .neverSynced + } + + let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600 + + if hoursSinceSync < 1 { + return .recentlySynced + } else if hoursSinceSync < 24 { + return .syncedToday + } else if hoursSinceSync < 72 { + return .stale + } else { + return .veryStale + } + } + + enum SyncStatus { + case recentlySynced + case syncedToday + case stale + case veryStale + case neverSynced + + var icon: String { + switch self { + case .recentlySynced: return "checkmark.circle.fill" + case .syncedToday: return "checkmark.circle" + case .stale: return "exclamationmark.circle" + case .veryStale: return "exclamationmark.triangle" + case .neverSynced: return "xmark.circle" + } + } + + var description: String { + switch self { + case .recentlySynced: return "Kürzlich synchronisiert" + case .syncedToday: return "Heute synchronisiert" + case .stale: return "Sync überfällig" + case .veryStale: return "Lange nicht synchronisiert" + case .neverSynced: return "Nie synchronisiert" + } + } + } +} + +// MARK: - Constants +enum HealthBridgeConstants { + static let bundleIdentifier = "com.healthbridge.merged" + static let displayName = "HealthBridge" + static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes + static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance +} diff --git a/HealthBridge/README.md b/HealthBridge/README.md new file mode 100644 index 0000000..795999a --- /dev/null +++ b/HealthBridge/README.md @@ -0,0 +1,140 @@ +# HealthBridge + +Intelligente Health-Daten-Synchronisation für iOS – Eine "Single Source of Truth" für Gesundheitsdaten. + +## Übersicht + +HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück. + +## Features + +### Source Discovery +- Automatische Erkennung aller verbundenen Datenquellen +- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps) +- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle + +### Konflikt-Erkennung +- Automatische Erkennung von Datenkonflikten zwischen Quellen +- Zeitfenster-basierte Analyse (15-Minuten-Intervalle) +- Schweregrad-Klassifizierung (minor, moderate, significant, major) + +### Merge-Strategien +- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2) +- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen +- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte) +- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt +- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert +- **Average**: Durchschnitt aller Quellen +- **Manual**: Benutzer entscheidet bei jedem Konflikt + +### UI-Komponenten +- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status +- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung +- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp +- **Quellen**: Übersicht aller erkannten Datenquellen + +### Background Sync +- Automatische Synchronisierung im Hintergrund +- Konfigurierbares Intervall (15 Min bis 2 Stunden) +- Push-Benachrichtigungen bei neuen Konflikten + +## Unterstützte Datentypen + +| Datentyp | Primärquelle | Strategie | +|----------|--------------|-----------| +| Schritte | Watch | Coverage + Higher | +| Herzfrequenz | Watch | Exclusive | +| Blutdruck | Watch D2 | Exclusive | +| SpO2 | Watch | Exclusive | +| Schlaf | Watch | Priority | +| Distanz | Watch/iPhone | Coverage + Higher | +| Stockwerke | iPhone | Exclusive | +| Aktive Energie | Watch | Coverage + Higher | + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Apple Health │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ HealthBridge App │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ▲ ▲ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │SourceManager│ │ RuleEngine │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SyncCoordinator │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Projektstruktur + +``` +HealthBridge/ +├── App/ +│ └── HealthBridgeApp.swift # App-Entry, Background Tasks +├── Models/ +│ ├── HealthDataTypes.swift # Datentypen, TimeWindow +│ ├── Source.swift # HealthSource, SourceReading +│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule +├── Services/ +│ ├── HealthKitManager.swift # HealthKit-Integration +│ ├── SourceManager.swift # Source Discovery & Management +│ ├── DataReader.swift # Daten lesen, Konflikte erkennen +│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden +│ ├── MergeEngine.swift # Konflikte analysieren & lösen +│ ├── DataWriter.swift # Daten zurückschreiben +│ └── SyncCoordinator.swift # Orchestrierung aller Services +├── Views/ +│ ├── ContentView.swift # Tab-Navigation +│ ├── DashboardView.swift # Hauptübersicht +│ ├── ConflictsView.swift # Konflikt-Liste & Detail +│ ├── RulesView.swift # Regelwerk-Editor +│ ├── SourcesView.swift # Quellen-Übersicht +│ ├── SettingsView.swift # Einstellungen +│ └── Components/ +│ └── HealthChart.swift # Diagramm-Komponenten +├── ViewModels/ +│ └── DashboardViewModel.swift # Dashboard-Logik +├── Utils/ +│ ├── Extensions.swift # Swift-Erweiterungen +│ └── NotificationManager.swift # Push-Benachrichtigungen +└── Resources/ + ├── Info.plist # App-Konfiguration + └── HealthBridge.entitlements # HealthKit-Berechtigungen +``` + +## Voraussetzungen + +- iOS 16.0+ +- Xcode 15.0+ +- Apple Developer Account (für HealthKit-Entitlements) +- Physisches Gerät (HealthKit nicht im Simulator verfügbar) + +## Installation + +1. Projekt in Xcode öffnen +2. Team für Code Signing auswählen +3. HealthKit-Capability aktivieren +4. Auf physischem Gerät ausführen + +## Berechtigungen + +Die App benötigt folgende Berechtigungen: +- **HealthKit Read**: Lesen aller Gesundheitsdaten +- **HealthKit Write**: Schreiben gemergter Daten +- **Background App Refresh**: Für automatische Synchronisierung +- **Notifications**: Für Konflikt-Benachrichtigungen + +## Lizenz + +MIT License diff --git a/HealthBridge/Resources/HealthBridge.entitlements b/HealthBridge/Resources/HealthBridge.entitlements new file mode 100644 index 0000000..279493a --- /dev/null +++ b/HealthBridge/Resources/HealthBridge.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + health-records + + com.apple.developer.healthkit.background-delivery + + aps-environment + development + + diff --git a/HealthBridge/Resources/Info.plist b/HealthBridge/Resources/Info.plist new file mode 100644 index 0000000..2c71a66 --- /dev/null +++ b/HealthBridge/Resources/Info.plist @@ -0,0 +1,74 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + HealthBridge + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchScreenBackground + UIImageName + LaunchIcon + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIRequiredDeviceCapabilities + + armv7 + healthkit + + + + NSHealthShareUsageDescription + HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen. + NSHealthUpdateUsageDescription + HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten. + + + UIBackgroundModes + + fetch + processing + + BGTaskSchedulerPermittedIdentifiers + + com.healthbridge.sync + com.healthbridge.cleanup + + + diff --git a/HealthBridge/Services/DataReader.swift b/HealthBridge/Services/DataReader.swift new file mode 100644 index 0000000..2eab74a --- /dev/null +++ b/HealthBridge/Services/DataReader.swift @@ -0,0 +1,488 @@ +import Foundation +import HealthKit +import Combine + +// MARK: - Data Reader +@MainActor +class DataReader: ObservableObject { + static let shared = DataReader() + + private let healthKitManager = HealthKitManager.shared + private let sourceManager = SourceManager.shared + + @Published var isReading = false + @Published var lastReadDate: Date? + @Published var detectedConflicts: [Conflict] = [] + @Published var readingProgress: Double = 0 + + private init() {} + + // MARK: - Fetch Data by Type and Date Range + + func fetchData( + for dataType: HealthDataType, + from startDate: Date, + to endDate: Date, + groupByWindow intervalMinutes: Int = 15 + ) async throws -> [TimeWindowData] { + isReading = true + defer { isReading = false } + + let samples = try await healthKitManager.fetchSamples( + for: dataType, + from: startDate, + to: endDate + ) + + // Group samples by source + let groupedBySource = groupBySource(samples: samples, dataType: dataType) + + // Create time windows + let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes) + + // Assign samples to windows + var windowDataList: [TimeWindowData] = [] + + for window in windows { + let windowData = createWindowData( + window: window, + dataType: dataType, + groupedBySource: groupedBySource + ) + windowDataList.append(windowData) + } + + lastReadDate = Date() + return windowDataList + } + + private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] { + var grouped: [String: [HKSample]] = [:] + + for sample in samples { + let sourceId = sample.sourceRevision.source.bundleIdentifier + if grouped[sourceId] == nil { + grouped[sourceId] = [] + } + grouped[sourceId]?.append(sample) + } + + return grouped + } + + private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] { + var windows: [TimeWindow] = [] + var current = start + + while current < end { + let windowEnd = min( + Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!, + end + ) + windows.append(TimeWindow(start: current, end: windowEnd)) + current = windowEnd + } + + return windows + } + + private func createWindowData( + window: TimeWindow, + dataType: HealthDataType, + groupedBySource: [String: [HKSample]] + ) -> TimeWindowData { + var readings: [SourceReading] = [] + + for (sourceId, samples) in groupedBySource { + let windowSamples = samples.filter { sample in + sample.startDate < window.end && sample.endDate > window.start + } + + if !windowSamples.isEmpty { + let reading = createReading( + from: windowSamples, + sourceId: sourceId, + dataType: dataType, + window: window + ) + readings.append(reading) + } + } + + let hasConflict = detectConflict(in: readings, dataType: dataType) + + return TimeWindowData( + timeWindow: window, + dataType: dataType, + readings: readings, + hasConflict: hasConflict + ) + } + + private func createReading( + from samples: [HKSample], + sourceId: String, + dataType: HealthDataType, + window: TimeWindow + ) -> SourceReading { + let value: Double + var secondaryValue: Double? = nil + + switch dataType { + case .steps, .floorsClimbed, .activeEnergy, .distance: + // Sum up values for cumulative types + value = samples.compactMap { sample -> Double? in + guard let quantitySample = sample as? HKQuantitySample else { return nil } + return quantitySample.quantity.doubleValue(for: dataType.hkUnit) + }.reduce(0, +) + + case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen: + // Average for rate-based types + let values = samples.compactMap { sample -> Double? in + guard let quantitySample = sample as? HKQuantitySample else { return nil } + return quantitySample.quantity.doubleValue(for: dataType.hkUnit) + } + value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count) + + case .bloodPressureSystolic, .bloodPressureDiastolic: + // For blood pressure, we need to handle correlations + let values = samples.compactMap { sample -> Double? in + guard let quantitySample = sample as? HKQuantitySample else { return nil } + return quantitySample.quantity.doubleValue(for: dataType.hkUnit) + } + value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count) + + case .sleep: + // Sum up sleep duration + value = samples.reduce(0) { acc, sample in + acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600 + } + } + + let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId } + let category = source?.category ?? sourceManager.classifySource(sourceId) + + return SourceReading( + sourceId: sourceId, + sourceName: source?.name ?? sourceId, + sourceCategory: category, + value: value, + secondaryValue: secondaryValue, + timestamp: window.start, + originalRecordId: samples.first?.uuid.uuidString, + quality: samples.isEmpty ? .missing : .complete + ) + } + + // MARK: - Conflict Detection + + private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool { + // No conflict if less than 2 readings + guard readings.count >= 2 else { return false } + + // Filter out zero values (device wasn't tracking) + let nonZeroReadings = readings.filter { $0.value > 0 } + guard nonZeroReadings.count >= 2 else { return false } + + // Check if values differ significantly + let values = nonZeroReadings.map { $0.value } + guard let minVal = values.min(), let maxVal = values.max() else { return false } + + // Threshold varies by data type + let threshold = conflictThreshold(for: dataType) + + if minVal == 0 { + return maxVal > threshold.absoluteThreshold + } + + let percentDiff = (maxVal - minVal) / minVal * 100 + return percentDiff > threshold.percentageThreshold + } + + private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold { + switch dataType { + case .steps: + return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100) + case .distance: + return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m + case .heartRate: + return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10) + case .bloodPressureSystolic, .bloodPressureDiastolic: + return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5) + case .bloodOxygen: + return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2) + case .floorsClimbed: + return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2) + case .activeEnergy: + return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50) + case .sleep: + return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min + case .restingHeartRate: + return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5) + case .heartRateVariability: + return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10) + case .respiratoryRate: + return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2) + } + } + + // MARK: - Detect All Conflicts + + func detectConflicts( + for date: Date, + dataTypes: [HealthDataType] = HealthDataType.allCases + ) async throws -> [Conflict] { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + var allConflicts: [Conflict] = [] + + for (index, dataType) in dataTypes.enumerated() { + readingProgress = Double(index) / Double(dataTypes.count) + + do { + let windowData = try await fetchData( + for: dataType, + from: startOfDay, + to: endOfDay + ) + + let conflicts = windowData + .filter { $0.hasConflict } + .map { data in + Conflict( + dataType: dataType, + timeWindow: data.timeWindow, + readings: data.readings, + status: .pending + ) + } + + allConflicts.append(contentsOf: conflicts) + } catch { + print("Failed to detect conflicts for \(dataType): \(error)") + } + } + + readingProgress = 1.0 + detectedConflicts = allConflicts + return allConflicts + } + + // MARK: - Data Gaps Detection + + func detectGaps( + for dataType: HealthDataType, + from startDate: Date, + to endDate: Date, + expectedIntervalMinutes: Int = 15 + ) async throws -> [DataGap] { + let samples = try await healthKitManager.fetchSamples( + for: dataType, + from: startDate, + to: endDate + ) + + guard !samples.isEmpty else { + return [DataGap( + dataType: dataType, + timeWindow: TimeWindow(start: startDate, end: endDate), + expectedRecordCount: 0, + actualRecordCount: 0 + )] + } + + let sortedSamples = samples.sorted { $0.startDate < $1.startDate } + var gaps: [DataGap] = [] + let expectedInterval = TimeInterval(expectedIntervalMinutes * 60) + + // Check gap at start + if let firstSample = sortedSamples.first, + firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 { + gaps.append(DataGap( + dataType: dataType, + timeWindow: TimeWindow(start: startDate, end: firstSample.startDate), + expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval), + actualRecordCount: 0 + )) + } + + // Check gaps between samples + for i in 0..<(sortedSamples.count - 1) { + let current = sortedSamples[i] + let next = sortedSamples[i + 1] + let gap = next.startDate.timeIntervalSince(current.endDate) + + if gap > expectedInterval * 2 { + gaps.append(DataGap( + dataType: dataType, + timeWindow: TimeWindow(start: current.endDate, end: next.startDate), + expectedRecordCount: Int(gap / expectedInterval), + actualRecordCount: 0 + )) + } + } + + // Check gap at end + if let lastSample = sortedSamples.last, + endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 { + gaps.append(DataGap( + dataType: dataType, + timeWindow: TimeWindow(start: lastSample.endDate, end: endDate), + expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval), + actualRecordCount: 0 + )) + } + + return gaps + } + + // MARK: - Aggregated Data + + func fetchDailySummary(for date: Date) async throws -> DailySummary { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + var summary = DailySummary(date: date) + + for dataType in HealthDataType.allCases { + do { + let samples = try await healthKitManager.fetchSamples( + for: dataType, + from: startOfDay, + to: endOfDay + ) + + let value = aggregateValue(samples: samples, dataType: dataType) + summary.values[dataType] = value + + // Check for conflicts + let windowData = try await fetchData( + for: dataType, + from: startOfDay, + to: endOfDay + ) + let conflictCount = windowData.filter { $0.hasConflict }.count + summary.conflictCounts[dataType] = conflictCount + } catch { + print("Failed to fetch \(dataType) for summary: \(error)") + } + } + + return summary + } + + private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double { + switch dataType { + case .steps, .floorsClimbed, .activeEnergy, .distance: + return samples.compactMap { sample -> Double? in + guard let quantitySample = sample as? HKQuantitySample else { return nil } + return quantitySample.quantity.doubleValue(for: dataType.hkUnit) + }.reduce(0, +) + + case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, + .bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic: + let values = samples.compactMap { sample -> Double? in + guard let quantitySample = sample as? HKQuantitySample else { return nil } + return quantitySample.quantity.doubleValue(for: dataType.hkUnit) + } + return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count) + + case .sleep: + return samples.reduce(0) { acc, sample in + acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600 + } + } + } +} + +// MARK: - Supporting Types + +struct TimeWindowData: Identifiable { + let id = UUID() + let timeWindow: TimeWindow + let dataType: HealthDataType + let readings: [SourceReading] + let hasConflict: Bool + + var primaryReading: SourceReading? { + readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority } + } + + var conflictSeverity: ConflictSeverity? { + guard hasConflict, readings.count >= 2 else { return nil } + + let values = readings.map { $0.value }.filter { $0 > 0 } + guard let min = values.min(), let max = values.max(), min > 0 else { return nil } + + let percentDiff = (max - min) / min * 100 + + if percentDiff < 5 { return .minor } + if percentDiff < 20 { return .moderate } + if percentDiff < 50 { return .significant } + return .major + } +} + +struct ConflictThreshold { + let percentageThreshold: Double + let absoluteThreshold: Double +} + +struct DataGap: Identifiable { + let id = UUID() + let dataType: HealthDataType + let timeWindow: TimeWindow + let expectedRecordCount: Int + let actualRecordCount: Int + + var severity: GapSeverity { + let duration = timeWindow.duration + if duration < 3600 { return .minor } // < 1 hour + if duration < 4 * 3600 { return .moderate } // < 4 hours + if duration < 12 * 3600 { return .significant } // < 12 hours + return .major + } + + enum GapSeverity { + case minor, moderate, significant, major + } +} + +struct DailySummary { + let date: Date + var values: [HealthDataType: Double] = [:] + var conflictCounts: [HealthDataType: Int] = [:] + var lastUpdated = Date() + + var totalConflicts: Int { + conflictCounts.values.reduce(0, +) + } + + func formattedValue(for dataType: HealthDataType) -> String { + guard let value = values[dataType] else { return "–" } + + switch dataType { + case .steps, .floorsClimbed: + return String(format: "%.0f", value) + case .distance: + return String(format: "%.2f km", value) + case .heartRate, .restingHeartRate, .respiratoryRate: + return String(format: "%.0f %@", value, dataType.unit) + case .bloodPressureSystolic, .bloodPressureDiastolic: + return String(format: "%.0f mmHg", value) + case .bloodOxygen: + return String(format: "%.0f%%", value * 100) + case .activeEnergy: + return String(format: "%.0f kcal", value) + case .sleep: + let hours = Int(value) + let minutes = Int((value - Double(hours)) * 60) + return "\(hours)h \(minutes)min" + case .heartRateVariability: + return String(format: "%.0f ms", value) + } + } +} diff --git a/HealthBridge/Services/DataWriter.swift b/HealthBridge/Services/DataWriter.swift new file mode 100644 index 0000000..9af5808 --- /dev/null +++ b/HealthBridge/Services/DataWriter.swift @@ -0,0 +1,395 @@ +import Foundation +import HealthKit +import Combine + +// MARK: - Data Writer +@MainActor +class DataWriter: ObservableObject { + static let shared = DataWriter() + + private let healthKitManager = HealthKitManager.shared + private let healthStore = HKHealthStore() + + @Published var isWriting = false + @Published var writeProgress: Double = 0 + @Published var lastWriteDate: Date? + @Published var writtenRecords: [WrittenRecord] = [] + @Published var failedWrites: [FailedWrite] = [] + + private let processedRecordsKey = "healthbridge.processed.records" + + private init() { + loadProcessedRecords() + } + + // MARK: - Write Single Record + + func writeRecord(_ mergedRecord: MergedRecord) async throws -> WrittenRecord { + isWriting = true + defer { isWriting = false } + + // Check if already written + if isAlreadyWritten(mergedRecord) { + throw DataWriterError.duplicateRecord + } + + let metadata = createMetadata(from: mergedRecord) + + switch mergedRecord.dataType { + case .bloodPressureSystolic, .bloodPressureDiastolic: + // Blood pressure needs special handling + guard let diastolic = mergedRecord.secondaryValue else { + throw DataWriterError.missingSecondaryValue + } + try await writeBloodPressure( + systolic: mergedRecord.value, + diastolic: diastolic, + date: mergedRecord.timeWindow.start, + metadata: metadata + ) + + default: + try await writeSample( + dataType: mergedRecord.dataType, + value: mergedRecord.value, + date: mergedRecord.timeWindow.start, + metadata: metadata + ) + } + + let writtenRecord = WrittenRecord( + id: UUID(), + mergedRecordId: mergedRecord.id, + dataType: mergedRecord.dataType, + value: mergedRecord.value, + secondaryValue: mergedRecord.secondaryValue, + writtenAt: Date(), + timeWindow: mergedRecord.timeWindow + ) + + writtenRecords.append(writtenRecord) + markAsProcessed(mergedRecord) + lastWriteDate = Date() + + return writtenRecord + } + + // MARK: - Write Batch + + func writeBatch(_ mergedRecords: [MergedRecord]) async -> BatchWriteResult { + isWriting = true + defer { isWriting = false } + + var successful: [WrittenRecord] = [] + var failed: [FailedWrite] = [] + + for (index, record) in mergedRecords.enumerated() { + writeProgress = Double(index) / Double(mergedRecords.count) + + do { + let writtenRecord = try await writeRecord(record) + successful.append(writtenRecord) + } catch { + let failedWrite = FailedWrite( + mergedRecord: record, + error: error, + attemptedAt: Date() + ) + failed.append(failedWrite) + failedWrites.append(failedWrite) + } + } + + writeProgress = 1.0 + + return BatchWriteResult( + successful: successful, + failed: failed, + completedAt: Date() + ) + } + + // MARK: - Private Write Methods + + private func writeSample( + dataType: HealthDataType, + value: Double, + date: Date, + metadata: [String: Any] + ) async throws { + guard let quantityType = dataType.hkQuantityType else { + throw DataWriterError.unsupportedDataType + } + + let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value) + let sample = HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + metadata: metadata + ) + + try await healthStore.save(sample) + } + + private func writeBloodPressure( + systolic: Double, + diastolic: Double, + date: Date, + metadata: [String: Any] + ) async throws { + // Validate blood pressure values + let validation = BloodPressureHandler.shared.validate( + systolic: systolic, + diastolic: diastolic + ) + + if !validation.isValid { + throw DataWriterError.invalidValue(validation.issues.joined(separator: ", ")) + } + + guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic), + let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic), + let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else { + throw DataWriterError.unsupportedDataType + } + + let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic) + let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic) + + let systolicSample = HKQuantitySample( + type: systolicType, + quantity: systolicQuantity, + start: date, + end: date, + metadata: metadata + ) + + let diastolicSample = HKQuantitySample( + type: diastolicType, + quantity: diastolicQuantity, + start: date, + end: date, + metadata: metadata + ) + + let correlation = HKCorrelation( + type: correlationType, + start: date, + end: date, + objects: [systolicSample, diastolicSample], + metadata: metadata + ) + + try await healthStore.save(correlation) + } + + // MARK: - Metadata + + private func createMetadata(from record: MergedRecord) -> [String: Any] { + var metadata: [String: Any] = [ + HKMetadataKeyWasUserEntered: false, + "HealthBridgeSource": HealthBridgeConstants.bundleIdentifier, + "OriginalSourceId": record.originalSourceId, + "MergeStrategy": record.strategy.rawValue, + "MergedRecordId": record.id.uuidString, + "MergedAt": ISO8601DateFormatter().string(from: record.createdAt) + ] + + for (key, value) in record.metadata { + metadata["HB_\(key)"] = value + } + + return metadata + } + + // MARK: - Duplicate Prevention + + private var processedRecordIds: Set = [] + + private func loadProcessedRecords() { + if let data = UserDefaults.standard.data(forKey: processedRecordsKey), + let ids = try? JSONDecoder().decode(Set.self, from: data) { + processedRecordIds = ids + } + } + + private func saveProcessedRecords() { + if let data = try? JSONEncoder().encode(processedRecordIds) { + UserDefaults.standard.set(data, forKey: processedRecordsKey) + } + } + + private func isAlreadyWritten(_ record: MergedRecord) -> Bool { + let identifier = createRecordIdentifier(record) + return processedRecordIds.contains(identifier) + } + + private func markAsProcessed(_ record: MergedRecord) { + let identifier = createRecordIdentifier(record) + processedRecordIds.insert(identifier) + saveProcessedRecords() + + // Cleanup old records (keep last 7 days) + cleanupOldRecords() + } + + private func createRecordIdentifier(_ record: MergedRecord) -> String { + let components = [ + record.dataType.rawValue, + String(record.timeWindow.start.timeIntervalSince1970), + String(record.value) + ] + return components.joined(separator: "-") + } + + private func cleanupOldRecords() { + // Keep only identifiers that contain recent timestamps + let sevenDaysAgo = Date().addingTimeInterval(-7 * 24 * 60 * 60) + let cutoffTimestamp = sevenDaysAgo.timeIntervalSince1970 + + processedRecordIds = processedRecordIds.filter { identifier in + guard let parts = identifier.split(separator: "-").dropFirst().first, + let timestamp = Double(parts) else { + return false + } + return timestamp > cutoffTimestamp + } + + saveProcessedRecords() + } + + // MARK: - Delete Records + + func deleteHealthBridgeRecords( + for dataType: HealthDataType, + from startDate: Date, + to endDate: Date + ) async throws -> Int { + guard let sampleType = dataType.hkQuantityType else { + throw DataWriterError.unsupportedDataType + } + + let predicate = HKQuery.predicateForSamples( + withStart: startDate, + end: endDate, + options: .strictStartDate + ) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { [weak self] _, samplesOrNil, errorOrNil in + guard let self = self else { + continuation.resume(throwing: DataWriterError.unknownError) + return + } + + if let error = errorOrNil { + continuation.resume(throwing: error) + return + } + + guard let samples = samplesOrNil else { + continuation.resume(returning: 0) + return + } + + // Filter to only HealthBridge records + let healthBridgeSamples = samples.filter { sample in + if let metadata = sample.metadata, + let source = metadata["HealthBridgeSource"] as? String { + return source == HealthBridgeConstants.bundleIdentifier + } + return false + } + + guard !healthBridgeSamples.isEmpty else { + continuation.resume(returning: 0) + return + } + + Task { + do { + try await self.healthStore.delete(healthBridgeSamples) + continuation.resume(returning: healthBridgeSamples.count) + } catch { + continuation.resume(throwing: error) + } + } + } + + healthStore.execute(query) + } + } +} + +// MARK: - Supporting Types + +struct WrittenRecord: Identifiable, Codable { + let id: UUID + let mergedRecordId: UUID + let dataType: HealthDataType + let value: Double + let secondaryValue: Double? + let writtenAt: Date + let timeWindow: TimeWindow +} + +struct FailedWrite: Identifiable { + let id = UUID() + let mergedRecord: MergedRecord + let error: Error + let attemptedAt: Date + + var errorMessage: String { + error.localizedDescription + } +} + +struct BatchWriteResult { + let successful: [WrittenRecord] + let failed: [FailedWrite] + let completedAt: Date + + var successCount: Int { successful.count } + var failureCount: Int { failed.count } + var totalCount: Int { successCount + failureCount } + + var successRate: Double { + guard totalCount > 0 else { return 1.0 } + return Double(successCount) / Double(totalCount) + } +} + +// MARK: - Errors + +enum DataWriterError: LocalizedError { + case unsupportedDataType + case duplicateRecord + case missingSecondaryValue + case invalidValue(String) + case writeFailed(String) + case unknownError + + var errorDescription: String? { + switch self { + case .unsupportedDataType: + return "Dieser Datentyp wird nicht unterstützt" + case .duplicateRecord: + return "Dieser Datensatz wurde bereits geschrieben" + case .missingSecondaryValue: + return "Fehlender sekundärer Wert (z.B. diastolischer Blutdruck)" + case .invalidValue(let message): + return "Ungültiger Wert: \(message)" + case .writeFailed(let message): + return "Schreiben fehlgeschlagen: \(message)" + case .unknownError: + return "Unbekannter Fehler" + } + } +} diff --git a/HealthBridge/Services/HealthKitManager.swift b/HealthBridge/Services/HealthKitManager.swift new file mode 100644 index 0000000..9ba35d7 --- /dev/null +++ b/HealthBridge/Services/HealthKitManager.swift @@ -0,0 +1,391 @@ +import Foundation +import HealthKit +import Combine + +// MARK: - HealthKit Manager +@MainActor +class HealthKitManager: ObservableObject { + static let shared = HealthKitManager() + + private let healthStore = HKHealthStore() + + @Published var isAuthorized = false + @Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:] + @Published var discoveredSources: [HealthSource] = [] + @Published var sourceHealthStatus: [String: SourceHealthStatus] = [:] + @Published var lastError: Error? + + private init() {} + + // MARK: - Authorization + + var allQuantityTypes: Set { + var types = Set() + for dataType in HealthDataType.allCases { + if let quantityType = dataType.hkQuantityType { + types.insert(quantityType) + } + } + return types + } + + var allCategoryTypes: Set { + var types = Set() + for dataType in HealthDataType.allCases { + if let categoryType = dataType.hkCategoryType { + types.insert(categoryType) + } + } + return types + } + + var allSampleTypes: Set { + var types = Set() + allQuantityTypes.forEach { types.insert($0) } + allCategoryTypes.forEach { types.insert($0) } + return types + } + + func requestAuthorization() async throws { + guard HKHealthStore.isHealthDataAvailable() else { + throw HealthKitError.healthDataNotAvailable + } + + let typesToRead = allSampleTypes + let typesToWrite = allQuantityTypes + + try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) + isAuthorized = true + + await updateAuthorizationStatus() + await discoverSources() + } + + func updateAuthorizationStatus() async { + for dataType in HealthDataType.allCases { + if let quantityType = dataType.hkQuantityType { + let status = healthStore.authorizationStatus(for: quantityType) + authorizationStatus[dataType] = status + } else if let categoryType = dataType.hkCategoryType { + let status = healthStore.authorizationStatus(for: categoryType) + authorizationStatus[dataType] = status + } + } + } + + // MARK: - Source Discovery + + func discoverSources() async { + var allSources: [String: HealthSource] = [:] + + for dataType in HealthDataType.allCases { + guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else { + continue + } + + do { + let sources = try await fetchSources(for: sampleType) + for source in sources { + if var existingSource = allSources[source.bundleIdentifier] { + existingSource.supportedDataTypes.insert(dataType) + allSources[source.bundleIdentifier] = existingSource + } else { + var newSource = source + newSource.supportedDataTypes.insert(dataType) + allSources[source.bundleIdentifier] = newSource + } + } + } catch { + print("Failed to fetch sources for \(dataType): \(error)") + } + } + + discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority } + + // Update source health status + for source in discoveredSources { + await updateSourceHealth(source) + } + } + + private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] { + let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in + // Handled via continuation + } + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in + if let error = errorOrNil { + continuation.resume(throwing: error) + return + } + + guard let sources = sourcesOrNil else { + continuation.resume(returning: []) + return + } + + let healthSources = sources.map { HealthSource.from(hkSource: $0) } + continuation.resume(returning: healthSources) + } + + healthStore.execute(query) + } + } + + private func updateSourceHealth(_ source: HealthSource) async { + var recordCount = 0 + var lastActivity: Date? + + for dataType in source.supportedDataTypes { + if let quantityType = dataType.hkQuantityType { + let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) ) + // Simplified: just get count + if let count = try? await fetchRecordCount(for: quantityType, source: source) { + recordCount += count + } + if let date = try? await fetchLastActivityDate(for: quantityType, source: source) { + if lastActivity == nil || date > lastActivity! { + lastActivity = date + } + } + } + } + + let status = SourceHealthStatus( + id: source.id, + source: source, + lastSync: lastActivity, + recordCount: recordCount, + dataGaps: [], // TODO: Implement gap detection + overallQuality: recordCount > 0 ? .complete : .missing + ) + + sourceHealthStatus[source.id] = status + } + + private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int { + let calendar = Calendar.current + let now = Date() + let startOfDay = calendar.startOfDay(for: now) + + let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samplesOrNil, errorOrNil in + if let error = errorOrNil { + continuation.resume(throwing: error) + return + } + + let samples = samplesOrNil ?? [] + let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier } + continuation.resume(returning: matchingSamples.count) + } + + self.healthStore.execute(query) + } + } + + private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? { + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: sampleType, + predicate: nil, + limit: 1, + sortDescriptors: [sortDescriptor] + ) { _, samplesOrNil, errorOrNil in + if let error = errorOrNil { + continuation.resume(throwing: error) + return + } + + let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier } + continuation.resume(returning: matchingSample?.endDate) + } + + self.healthStore.execute(query) + } + } + + // MARK: - Source Classification + + func classifySource(_ source: HKSource) -> SourceCategory { + let bundleId = source.bundleIdentifier.lowercased() + + if bundleId.contains("healthbridge") { + return .healthBridge + } else if bundleId.contains("apple.health") && !bundleId.contains("watch") { + return .iPhone + } else if bundleId.contains("apple") && bundleId.contains("watch") { + return .watch + } else if bundleId.contains("huawei") { + return .thirdPartyWatch + } else if bundleId.contains("samsung") || bundleId.contains("galaxy") { + return .thirdPartyWatch + } else if bundleId.contains("fitbit") { + return .thirdPartyWatch + } else if bundleId.contains("garmin") { + return .thirdPartyWatch + } else if bundleId.contains("polar") { + return .thirdPartyWatch + } else if bundleId.contains("withings") { + return .thirdPartyWatch + } else { + return .thirdPartyApp + } + } + + func getSourceCapabilities(_ source: HealthSource) -> Set { + return source.supportedDataTypes + } + + // MARK: - Data Fetching (Basic) + + func fetchSamples( + for dataType: HealthDataType, + from startDate: Date, + to endDate: Date + ) async throws -> [HKSample] { + guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else { + throw HealthKitError.unsupportedDataType + } + + let predicate = HKQuery.predicateForSamples( + withStart: startDate, + end: endDate, + options: .strictStartDate + ) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)] + ) { _, samplesOrNil, errorOrNil in + if let error = errorOrNil { + continuation.resume(throwing: error) + return + } + continuation.resume(returning: samplesOrNil ?? []) + } + + self.healthStore.execute(query) + } + } + + // MARK: - Data Writing + + func writeSample( + dataType: HealthDataType, + value: Double, + secondaryValue: Double? = nil, + date: Date, + metadata: [String: Any]? = nil + ) async throws { + guard let quantityType = dataType.hkQuantityType else { + throw HealthKitError.unsupportedDataType + } + + let quantity = HKQuantity(unit: dataType.hkUnit, doubleValue: value) + let sample = HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + metadata: metadata + ) + + try await healthStore.save(sample) + } + + func writeBloodPressure( + systolic: Double, + diastolic: Double, + date: Date, + metadata: [String: Any]? = nil + ) async throws { + guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic), + let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else { + throw HealthKitError.unsupportedDataType + } + + let systolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: systolic) + let diastolicQuantity = HKQuantity(unit: .millimeterOfMercury(), doubleValue: diastolic) + + let systolicSample = HKQuantitySample( + type: systolicType, + quantity: systolicQuantity, + start: date, + end: date, + metadata: metadata + ) + + let diastolicSample = HKQuantitySample( + type: diastolicType, + quantity: diastolicQuantity, + start: date, + end: date, + metadata: metadata + ) + + // Create correlation for blood pressure + guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else { + throw HealthKitError.unsupportedDataType + } + + let correlation = HKCorrelation( + type: correlationType, + start: date, + end: date, + objects: [systolicSample, diastolicSample], + metadata: metadata + ) + + try await healthStore.save(correlation) + } +} + +// MARK: - HealthKit Errors +enum HealthKitError: LocalizedError { + case healthDataNotAvailable + case authorizationDenied + case unsupportedDataType + case noDataFound + case writeFailed(Error) + case queryFailed(Error) + + var errorDescription: String? { + switch self { + case .healthDataNotAvailable: + return "Health-Daten sind auf diesem Gerät nicht verfügbar" + case .authorizationDenied: + return "Zugriff auf Health-Daten wurde verweigert" + case .unsupportedDataType: + return "Dieser Datentyp wird nicht unterstützt" + case .noDataFound: + return "Keine Daten gefunden" + case .writeFailed(let error): + return "Schreiben fehlgeschlagen: \(error.localizedDescription)" + case .queryFailed(let error): + return "Abfrage fehlgeschlagen: \(error.localizedDescription)" + } + } +} + +// MARK: - HKSource Extension +extension HKSource { + convenience init(bundleIdentifier: String, name: String) { + // Note: This is a workaround since HKSource doesn't have a public initializer + // In production, sources come from HealthKit queries + fatalError("HKSource cannot be initialized directly - use source from HKSample") + } +} diff --git a/HealthBridge/Services/MergeEngine.swift b/HealthBridge/Services/MergeEngine.swift new file mode 100644 index 0000000..d7da73e --- /dev/null +++ b/HealthBridge/Services/MergeEngine.swift @@ -0,0 +1,287 @@ +import Foundation +import Combine + +// MARK: - Merge Engine +@MainActor +class MergeEngine: ObservableObject { + static let shared = MergeEngine() + + private let ruleEngine = RuleEngine.shared + private let dataReader = DataReader.shared + + @Published var pendingMerges: [MergeOperation] = [] + @Published var completedMerges: [MergeOperation] = [] + @Published var isMerging = false + @Published var mergeProgress: Double = 0 + + private init() {} + + // MARK: - Analyze Window + + func analyze(windowData: TimeWindowData) -> WindowAnalysis { + let readings = windowData.readings + let dataType = windowData.dataType + + // No analysis needed for single reading + if readings.count <= 1 { + return WindowAnalysis( + windowData: windowData, + hasConflict: false, + conflictSeverity: nil, + recommendedReading: readings.first, + alternativeReadings: [], + confidence: .high, + analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle" + ) + } + + // Apply rule to get recommendation + let result = ruleEngine.applyRule(to: readings, dataType: dataType) + + // Calculate conflict severity + let values = readings.map { $0.value }.filter { $0 > 0 } + var severity: ConflictSeverity? = nil + + if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 { + let percentDiff = (max - min) / min * 100 + if percentDiff >= 5 { + if percentDiff < 10 { severity = .minor } + else if percentDiff < 25 { severity = .moderate } + else if percentDiff < 50 { severity = .significant } + else { severity = .major } + } + } + + let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id } + + return WindowAnalysis( + windowData: windowData, + hasConflict: windowData.hasConflict, + conflictSeverity: severity, + recommendedReading: result.selectedReading, + alternativeReadings: alternativeReadings, + confidence: result.confidence, + analysisNotes: result.reason + ) + } + + // MARK: - Resolve Conflict + + func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? { + guard let selectedReading = result.selectedReading else { + return nil + } + + return ConflictResolution( + resolvedValue: selectedReading.value, + secondaryResolvedValue: selectedReading.secondaryValue, + winningSourceId: selectedReading.sourceId, + strategy: result.strategy, + isManual: result.strategy == .manual + ) + } + + func resolveConflictManually( + _ conflict: Conflict, + selectedReadingId: UUID + ) -> ConflictResolution? { + guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else { + return nil + } + + return ConflictResolution( + resolvedValue: selectedReading.value, + secondaryResolvedValue: selectedReading.secondaryValue, + winningSourceId: selectedReading.sourceId, + strategy: .manual, + isManual: true + ) + } + + // MARK: - Create Merged Record + + func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord { + return MergedRecord( + id: UUID(), + dataType: conflict.dataType, + timeWindow: conflict.timeWindow, + value: resolution.resolvedValue, + secondaryValue: resolution.secondaryResolvedValue, + originalSourceId: resolution.winningSourceId, + strategy: resolution.strategy, + createdAt: Date(), + metadata: [ + "conflictId": conflict.id.uuidString, + "originalSourceCount": String(conflict.readings.count), + "isManualResolution": String(resolution.isManual) + ] + ) + } + + // MARK: - Batch Processing + + func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] { + isMerging = true + defer { isMerging = false } + + var operations: [MergeOperation] = [] + + for (index, conflict) in conflicts.enumerated() { + mergeProgress = Double(index) / Double(conflicts.count) + + let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType) + + if result.confidence == .requiresManual || + ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) { + // Add to pending for manual review + let operation = MergeOperation( + conflict: conflict, + status: .pendingManualReview, + result: result + ) + pendingMerges.append(operation) + operations.append(operation) + } else if let resolution = resolveConflict(conflict, using: result) { + // Auto-resolve + let mergedRecord = createMergedRecord(from: conflict, resolution: resolution) + let operation = MergeOperation( + conflict: conflict, + status: .resolved, + result: result, + resolution: resolution, + mergedRecord: mergedRecord + ) + completedMerges.append(operation) + operations.append(operation) + } + } + + mergeProgress = 1.0 + return operations + } + + // MARK: - Daily Merge + + func performDailyMerge(for date: Date) async throws -> DailyMergeReport { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + var report = DailyMergeReport(date: date) + + for dataType in HealthDataType.allCases { + do { + let windowData = try await dataReader.fetchData( + for: dataType, + from: startOfDay, + to: endOfDay + ) + + let conflictWindows = windowData.filter { $0.hasConflict } + report.totalConflicts += conflictWindows.count + + for window in conflictWindows { + let analysis = analyze(windowData: window) + + if analysis.confidence == .requiresManual { + report.pendingManualReview += 1 + } else { + report.autoResolved += 1 + } + + report.analysesByType[dataType, default: []].append(analysis) + } + } catch { + report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)") + } + } + + return report + } +} + +// MARK: - Supporting Types + +struct WindowAnalysis { + let windowData: TimeWindowData + let hasConflict: Bool + let conflictSeverity: ConflictSeverity? + let recommendedReading: SourceReading? + let alternativeReadings: [SourceReading] + let confidence: RuleConfidence + let analysisNotes: String + + var recommendedValue: Double? { + recommendedReading?.value + } + + var valueDifference: Double? { + guard let recommended = recommendedReading?.value, + let alternative = alternativeReadings.first?.value else { + return nil + } + return abs(recommended - alternative) + } +} + +struct MergeOperation: Identifiable { + let id = UUID() + let conflict: Conflict + var status: MergeStatus + let result: RuleApplicationResult + var resolution: ConflictResolution? + var mergedRecord: MergedRecord? + let createdAt = Date() + var processedAt: Date? + + enum MergeStatus { + case pending + case pendingManualReview + case resolved + case written + case failed + } +} + +struct MergedRecord: Identifiable, Codable { + let id: UUID + let dataType: HealthDataType + let timeWindow: TimeWindow + let value: Double + let secondaryValue: Double? + let originalSourceId: String + let strategy: MergeStrategy + let createdAt: Date + var writtenAt: Date? + var healthKitRecordId: String? + var metadata: [String: String] + + var formattedValue: String { + if value == floor(value) { + return String(format: "%.0f", value) + } + return String(format: "%.1f", value) + } +} + +struct DailyMergeReport { + let date: Date + var totalConflicts = 0 + var autoResolved = 0 + var pendingManualReview = 0 + var analysesByType: [HealthDataType: [WindowAnalysis]] = [:] + var errors: [String] = [] + let generatedAt = Date() + + var successRate: Double { + guard totalConflicts > 0 else { return 1.0 } + return Double(autoResolved) / Double(totalConflicts) + } + + var summary: String { + if totalConflicts == 0 { + return "Keine Konflikte gefunden" + } + return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung" + } +} diff --git a/HealthBridge/Services/RuleEngine.swift b/HealthBridge/Services/RuleEngine.swift new file mode 100644 index 0000000..0ed13c6 --- /dev/null +++ b/HealthBridge/Services/RuleEngine.swift @@ -0,0 +1,571 @@ +import Foundation +import Combine + +// MARK: - Rule Engine +@MainActor +class RuleEngine: ObservableObject { + static let shared = RuleEngine() + + @Published var rules: [HealthDataType: MergeRule] = [:] + @Published var isLoaded = false + + private let storage = RuleStorageManager() + private let sourceManager = SourceManager.shared + + private init() { + loadRules() + } + + // MARK: - Rule Loading + + func loadRules() { + let savedRules = storage.loadRules() + + if savedRules.isEmpty { + // Initialize with defaults + for dataType in HealthDataType.allCases { + rules[dataType] = MergeRule.defaultRule(for: dataType) + } + saveRules() + } else { + rules = savedRules + } + + isLoaded = true + } + + func saveRules() { + storage.saveRules(rules) + } + + // MARK: - Rule Access + + func getRule(for dataType: HealthDataType) -> MergeRule { + return rules[dataType] ?? MergeRule.defaultRule(for: dataType) + } + + func setRule(_ rule: MergeRule, for dataType: HealthDataType) { + rules[dataType] = rule + saveRules() + } + + func resetToDefault(for dataType: HealthDataType) { + rules[dataType] = MergeRule.defaultRule(for: dataType) + saveRules() + } + + func resetAllToDefaults() { + for dataType in HealthDataType.allCases { + rules[dataType] = MergeRule.defaultRule(for: dataType) + } + saveRules() + } + + // MARK: - Rule Application + + func applyRule( + to readings: [SourceReading], + dataType: HealthDataType + ) -> RuleApplicationResult { + let rule = getRule(for: dataType) + + // Filter out empty/zero readings for most strategies + let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete } + + guard !validReadings.isEmpty else { + return RuleApplicationResult( + selectedReading: nil, + strategy: rule.strategy, + confidence: .low, + reason: "Keine gültigen Werte vorhanden" + ) + } + + // If only one valid reading, no conflict + if validReadings.count == 1 { + return RuleApplicationResult( + selectedReading: validReadings[0], + strategy: rule.strategy, + confidence: .high, + reason: "Nur eine Quelle verfügbar" + ) + } + + // Apply strategy + switch rule.strategy { + case .exclusive: + return applyExclusiveStrategy(readings: validReadings, rule: rule) + + case .priority: + return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType) + + case .higherWins: + return applyHigherWinsStrategy(readings: validReadings, rule: rule) + + case .lowerWins: + return applyLowerWinsStrategy(readings: validReadings, rule: rule) + + case .average: + return applyAverageStrategy(readings: validReadings, rule: rule) + + case .coverage: + return applyCoverageStrategy(readings: validReadings, rule: rule) + + case .coverageThenHigher: + return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule) + + case .mostRecent: + return applyMostRecentStrategy(readings: validReadings, rule: rule) + + case .manual: + return RuleApplicationResult( + selectedReading: nil, + strategy: .manual, + confidence: .requiresManual, + reason: "Manuelle Entscheidung erforderlich" + ) + } + } + + // MARK: - Strategy Implementations + + private func applyExclusiveStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + // If primary source is specified, use it + if let primaryId = rule.primarySourceId, + let reading = readings.first(where: { $0.sourceId == primaryId }) { + return RuleApplicationResult( + selectedReading: reading, + strategy: .exclusive, + confidence: .high, + reason: "Exklusive Quelle: \(reading.sourceName)" + ) + } + + // Otherwise use highest priority source + let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority } + if let first = sorted.first { + return RuleApplicationResult( + selectedReading: first, + strategy: .exclusive, + confidence: .high, + reason: "Höchste Priorität: \(first.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .exclusive, + confidence: .low, + reason: "Keine geeignete Quelle gefunden" + ) + } + + private func applyPriorityStrategy( + readings: [SourceReading], + rule: MergeRule, + dataType: HealthDataType + ) -> RuleApplicationResult { + // Sort by user-defined priority, then by category priority + let sorted = readings.sorted { r1, r2 in + let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority + let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority + return p1 > p2 + } + + if let first = sorted.first { + return RuleApplicationResult( + selectedReading: first, + strategy: .priority, + confidence: .high, + reason: "Höchste Priorität: \(first.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .priority, + confidence: .low, + reason: "Keine Quelle mit Priorität gefunden" + ) + } + + private func applyHigherWinsStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + let sorted = readings.sorted { $0.value > $1.value } + + if let highest = sorted.first { + // Check if there's a significant difference + let values = readings.map { $0.value } + let spread = (values.max() ?? 0) - (values.min() ?? 0) + let avgValue = values.reduce(0, +) / Double(values.count) + let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0 + + let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium + + return RuleApplicationResult( + selectedReading: highest, + strategy: .higherWins, + confidence: confidence, + reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .higherWins, + confidence: .low, + reason: "Keine Werte zum Vergleich" + ) + } + + private func applyLowerWinsStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + let sorted = readings.sorted { $0.value < $1.value } + + if let lowest = sorted.first { + return RuleApplicationResult( + selectedReading: lowest, + strategy: .lowerWins, + confidence: .medium, + reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .lowerWins, + confidence: .low, + reason: "Keine Werte zum Vergleich" + ) + } + + private func applyAverageStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + let values = readings.map { $0.value } + let average = values.reduce(0, +) / Double(values.count) + + // Create a synthetic reading for the average + let syntheticReading = SourceReading( + sourceId: HealthBridgeConstants.bundleIdentifier, + sourceName: "Durchschnitt", + sourceCategory: .healthBridge, + value: average, + timestamp: readings.first?.timestamp ?? Date(), + quality: .complete + ) + + return RuleApplicationResult( + selectedReading: syntheticReading, + strategy: .average, + confidence: .medium, + reason: "Durchschnitt aus \(readings.count) Quellen" + ) + } + + private func applyCoverageStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + // Prefer readings with complete quality + let completeReadings = readings.filter { $0.quality == .complete } + + if completeReadings.count == 1 { + return RuleApplicationResult( + selectedReading: completeReadings[0], + strategy: .coverage, + confidence: .high, + reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)" + ) + } + + // If multiple complete readings, fall back to priority + if !completeReadings.isEmpty { + let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority } + if let first = sorted.first { + return RuleApplicationResult( + selectedReading: first, + strategy: .coverage, + confidence: .medium, + reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)" + ) + } + } + + // No complete readings, use any reading with highest priority + let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority } + if let first = sorted.first { + return RuleApplicationResult( + selectedReading: first, + strategy: .coverage, + confidence: .low, + reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .coverage, + confidence: .low, + reason: "Keine Quelle mit Daten gefunden" + ) + } + + private func applyCoverageThenHigherStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + // First check if one source has data and others don't (coverage) + let nonZeroReadings = readings.filter { $0.value > 0 } + let zeroReadings = readings.filter { $0.value == 0 } + + // If only one source has data, it wins on coverage + if nonZeroReadings.count == 1 && !zeroReadings.isEmpty { + return RuleApplicationResult( + selectedReading: nonZeroReadings[0], + strategy: .coverageThenHigher, + confidence: .high, + reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)" + ) + } + + // Multiple sources have data, use higher wins + if nonZeroReadings.count > 1 { + let sorted = nonZeroReadings.sorted { $0.value > $1.value } + if let highest = sorted.first { + return RuleApplicationResult( + selectedReading: highest, + strategy: .coverageThenHigher, + confidence: .medium, + reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)" + ) + } + } + + // Fallback + if let first = readings.first { + return RuleApplicationResult( + selectedReading: first, + strategy: .coverageThenHigher, + confidence: .low, + reason: "Fallback auf erste Quelle" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .coverageThenHigher, + confidence: .low, + reason: "Keine Daten verfügbar" + ) + } + + private func applyMostRecentStrategy( + readings: [SourceReading], + rule: MergeRule + ) -> RuleApplicationResult { + let sorted = readings.sorted { $0.timestamp > $1.timestamp } + + if let mostRecent = sorted.first { + return RuleApplicationResult( + selectedReading: mostRecent, + strategy: .mostRecent, + confidence: .high, + reason: "Neuester Wert von \(mostRecent.sourceName)" + ) + } + + return RuleApplicationResult( + selectedReading: nil, + strategy: .mostRecent, + confidence: .low, + reason: "Keine Zeitstempel verfügbar" + ) + } + + // MARK: - Threshold Check + + func shouldRequestManualReview( + readings: [SourceReading], + dataType: HealthDataType + ) -> Bool { + let rule = getRule(for: dataType) + + guard let threshold = rule.thresholdForManualReview else { + return rule.strategy == .manual + } + + let values = readings.map { $0.value }.filter { $0 > 0 } + guard values.count >= 2, + let min = values.min(), + let max = values.max(), + min > 0 else { + return false + } + + let percentDiff = (max - min) / min * 100 + return percentDiff > threshold + } +} + +// MARK: - Rule Application Result +struct RuleApplicationResult { + let selectedReading: SourceReading? + let strategy: MergeStrategy + let confidence: RuleConfidence + let reason: String + + var resolvedValue: Double? { + selectedReading?.value + } + + var winningSourceId: String? { + selectedReading?.sourceId + } +} + +enum RuleConfidence: String, Codable { + case high = "high" + case medium = "medium" + case low = "low" + case requiresManual = "requires_manual" + + var displayName: String { + switch self { + case .high: return "Hohe Sicherheit" + case .medium: return "Mittlere Sicherheit" + case .low: return "Geringe Sicherheit" + case .requiresManual: return "Manuelle Prüfung" + } + } + + var icon: String { + switch self { + case .high: return "checkmark.seal.fill" + case .medium: return "checkmark.seal" + case .low: return "questionmark.circle" + case .requiresManual: return "hand.raised.fill" + } + } +} + +// MARK: - Rule Storage Manager +class RuleStorageManager { + private let userDefaults = UserDefaults.standard + private let rulesKey = "healthbridge.merge.rules" + + func saveRules(_ rules: [HealthDataType: MergeRule]) { + do { + let data = try JSONEncoder().encode(rules) + userDefaults.set(data, forKey: rulesKey) + } catch { + print("Failed to save rules: \(error)") + } + } + + func loadRules() -> [HealthDataType: MergeRule] { + guard let data = userDefaults.data(forKey: rulesKey) else { + return [:] + } + + do { + return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data) + } catch { + print("Failed to load rules: \(error)") + return [:] + } + } +} + +// MARK: - Blood Pressure Handler +class BloodPressureHandler { + static let shared = BloodPressureHandler() + + struct ValidationResult { + let isValid: Bool + let issues: [String] + } + + func validate(systolic: Double, diastolic: Double) -> ValidationResult { + var issues: [String] = [] + + // Range validation + if systolic < 70 || systolic > 200 { + issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)") + } + + if diastolic < 40 || diastolic > 130 { + issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)") + } + + // Plausibility check + if diastolic >= systolic { + issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein") + } + + if systolic - diastolic < 20 { + issues.append("Pulsdruck zu gering (< 20 mmHg)") + } + + if systolic - diastolic > 100 { + issues.append("Pulsdruck zu hoch (> 100 mmHg)") + } + + return ValidationResult(isValid: issues.isEmpty, issues: issues) + } + + func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification { + if systolic < 120 && diastolic < 80 { + return .normal + } else if systolic < 130 && diastolic < 80 { + return .elevated + } else if systolic < 140 || diastolic < 90 { + return .hypertensionStage1 + } else if systolic < 180 || diastolic < 120 { + return .hypertensionStage2 + } else { + return .hypertensiveCrisis + } + } + + enum BloodPressureClassification: String { + case normal = "Normal" + case elevated = "Erhöht" + case hypertensionStage1 = "Bluthochdruck Stufe 1" + case hypertensionStage2 = "Bluthochdruck Stufe 2" + case hypertensiveCrisis = "Hypertensive Krise" + + var color: String { + switch self { + case .normal: return "green" + case .elevated: return "yellow" + case .hypertensionStage1: return "orange" + case .hypertensionStage2: return "red" + case .hypertensiveCrisis: return "purple" + } + } + + var recommendation: String { + switch self { + case .normal: + return "Weiter so! Regelmässige Kontrolle empfohlen." + case .elevated: + return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz." + case .hypertensionStage1: + return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich." + case .hypertensionStage2: + return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig." + case .hypertensiveCrisis: + return "SOFORT medizinische Hilfe aufsuchen!" + } + } + } +} diff --git a/HealthBridge/Services/SourceManager.swift b/HealthBridge/Services/SourceManager.swift new file mode 100644 index 0000000..a19f0c5 --- /dev/null +++ b/HealthBridge/Services/SourceManager.swift @@ -0,0 +1,409 @@ +import Foundation +import HealthKit +import Combine + +// MARK: - Source Manager +@MainActor +class SourceManager: ObservableObject { + static let shared = SourceManager() + + @Published var sources: [HealthSource] = [] + @Published var sourceProfiles: [String: SourceProfile] = [:] + @Published var isDiscovering = false + + private let healthKitManager = HealthKitManager.shared + private let storage = SourceStorageManager() + + private init() {} + + // MARK: - Source Discovery + + func discoverSources() async { + isDiscovering = true + defer { isDiscovering = false } + + await healthKitManager.discoverSources() + sources = healthKitManager.discoveredSources + + // Load saved source profiles and merge with discovered sources + let savedProfiles = storage.loadSourceProfiles() + for source in sources { + if let savedProfile = savedProfiles[source.bundleIdentifier] { + sourceProfiles[source.bundleIdentifier] = savedProfile + } else { + sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source) + } + } + } + + // MARK: - Source Classification + + func classifySource(_ bundleIdentifier: String) -> SourceCategory { + let lowercased = bundleIdentifier.lowercased() + + // HealthBridge + if lowercased.contains("healthbridge") { + return .healthBridge + } + + // Apple Devices + if lowercased.contains("com.apple") { + if lowercased.contains("watch") || lowercased.contains("nano") { + return .watch + } + if lowercased.contains("health") { + return .iPhone + } + } + + // Known Watch Brands + let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar", + "withings", "amazfit", "xiaomi", "honor", "oppo", "suunto", + "coros", "whoop", "oura"] + for brand in watchBrands { + if lowercased.contains(brand) { + return .thirdPartyWatch + } + } + + // Known Health Apps + let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper", + "mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"] + for app in healthApps { + if lowercased.contains(app) { + return .thirdPartyApp + } + } + + return .unknown + } + + // MARK: - Source Capabilities + + func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities { + let category = source.category + + switch category { + case .iPhone: + return SourceCapabilities( + canMeasureSteps: true, + canMeasureDistance: true, + canMeasureFloors: true, + canMeasureHeartRate: false, + canMeasureBloodPressure: false, + canMeasureBloodOxygen: false, + canMeasureSleep: false, + canMeasureWorkouts: true, + hasGPS: true, + hasBarometer: true, + hasAccelerometer: true + ) + + case .watch: + return SourceCapabilities( + canMeasureSteps: true, + canMeasureDistance: true, + canMeasureFloors: false, + canMeasureHeartRate: true, + canMeasureBloodPressure: false, + canMeasureBloodOxygen: true, + canMeasureSleep: true, + canMeasureWorkouts: true, + hasGPS: true, + hasBarometer: false, + hasAccelerometer: true + ) + + case .thirdPartyWatch: + // Check for specific features based on name + let name = source.name.lowercased() + let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d")) + + return SourceCapabilities( + canMeasureSteps: true, + canMeasureDistance: true, + canMeasureFloors: false, + canMeasureHeartRate: true, + canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor + canMeasureBloodOxygen: true, + canMeasureSleep: true, + canMeasureWorkouts: true, + hasGPS: true, + hasBarometer: false, + hasAccelerometer: true + ) + + case .thirdPartyApp: + return SourceCapabilities( + canMeasureSteps: true, + canMeasureDistance: true, + canMeasureFloors: false, + canMeasureHeartRate: false, + canMeasureBloodPressure: false, + canMeasureBloodOxygen: false, + canMeasureSleep: false, + canMeasureWorkouts: true, + hasGPS: true, + hasBarometer: false, + hasAccelerometer: false + ) + + case .healthBridge, .unknown: + return SourceCapabilities.empty + } + } + + // MARK: - Source Health + + func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport { + var dataGaps: [HealthDataType: [TimeWindow]] = [:] + var lastActivityByType: [HealthDataType: Date] = [:] + var recordCountByType: [HealthDataType: Int] = [:] + + let calendar = Calendar.current + let now = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: now)! + + for dataType in source.supportedDataTypes { + do { + let samples = try await healthKitManager.fetchSamples( + for: dataType, + from: yesterday, + to: now + ) + + let matchingSamples = samples.filter { + $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier + } + + recordCountByType[dataType] = matchingSamples.count + + if let lastSample = matchingSamples.last { + lastActivityByType[dataType] = lastSample.endDate + } + + // Detect gaps + let gaps = detectDataGaps( + in: matchingSamples, + from: yesterday, + to: now, + expectedInterval: 15 * 60 // 15 minutes + ) + if !gaps.isEmpty { + dataGaps[dataType] = gaps + } + } catch { + print("Error checking health for \(dataType): \(error)") + } + } + + let overallQuality: DataQuality + if recordCountByType.values.reduce(0, +) == 0 { + overallQuality = .missing + } else if dataGaps.isEmpty { + overallQuality = .complete + } else { + overallQuality = .partial + } + + return SourceHealthReport( + source: source, + lastActivityByType: lastActivityByType, + recordCountByType: recordCountByType, + dataGaps: dataGaps, + overallQuality: overallQuality, + checkedAt: Date() + ) + } + + private func detectDataGaps( + in samples: [HKSample], + from start: Date, + to end: Date, + expectedInterval: TimeInterval + ) -> [TimeWindow] { + guard !samples.isEmpty else { + return [TimeWindow(start: start, end: end)] + } + + var gaps: [TimeWindow] = [] + let sortedSamples = samples.sorted { $0.startDate < $1.startDate } + + // Check gap at beginning + if let firstSample = sortedSamples.first, + firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 { + gaps.append(TimeWindow(start: start, end: firstSample.startDate)) + } + + // Check gaps between samples + for i in 0..<(sortedSamples.count - 1) { + let currentEnd = sortedSamples[i].endDate + let nextStart = sortedSamples[i + 1].startDate + let gap = nextStart.timeIntervalSince(currentEnd) + + if gap > expectedInterval * 2 { + gaps.append(TimeWindow(start: currentEnd, end: nextStart)) + } + } + + // Check gap at end + if let lastSample = sortedSamples.last, + end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 { + gaps.append(TimeWindow(start: lastSample.endDate, end: end)) + } + + return gaps + } + + // MARK: - Priority Management + + func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) { + guard var profile = sourceProfiles[source.bundleIdentifier] else { return } + profile.priorities[dataType] = priority + sourceProfiles[source.bundleIdentifier] = profile + storage.saveSourceProfiles(sourceProfiles) + } + + func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int { + if let profile = sourceProfiles[source.bundleIdentifier], + let priority = profile.priorities[dataType] { + return priority + } + return source.category.priority + } + + func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] { + return sources + .filter { $0.supportedDataTypes.contains(dataType) } + .sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) } + } + + // MARK: - Source Enable/Disable + + func setEnabled(_ enabled: Bool, for source: HealthSource) { + guard var profile = sourceProfiles[source.bundleIdentifier] else { return } + profile.isEnabled = enabled + sourceProfiles[source.bundleIdentifier] = profile + storage.saveSourceProfiles(sourceProfiles) + } + + func isEnabled(_ source: HealthSource) -> Bool { + return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true + } +} + +// MARK: - Source Profile +struct SourceProfile: Codable { + let bundleIdentifier: String + var priorities: [HealthDataType: Int] + var isEnabled: Bool + var customName: String? + var notes: String? + let addedAt: Date + + init(source: HealthSource) { + self.bundleIdentifier = source.bundleIdentifier + self.priorities = [:] + self.isEnabled = true + self.customName = nil + self.notes = nil + self.addedAt = Date() + } +} + +// MARK: - Source Capabilities +struct SourceCapabilities { + let canMeasureSteps: Bool + let canMeasureDistance: Bool + let canMeasureFloors: Bool + let canMeasureHeartRate: Bool + let canMeasureBloodPressure: Bool + let canMeasureBloodOxygen: Bool + let canMeasureSleep: Bool + let canMeasureWorkouts: Bool + let hasGPS: Bool + let hasBarometer: Bool + let hasAccelerometer: Bool + + static let empty = SourceCapabilities( + canMeasureSteps: false, + canMeasureDistance: false, + canMeasureFloors: false, + canMeasureHeartRate: false, + canMeasureBloodPressure: false, + canMeasureBloodOxygen: false, + canMeasureSleep: false, + canMeasureWorkouts: false, + hasGPS: false, + hasBarometer: false, + hasAccelerometer: false + ) + + var supportedDataTypes: Set { + var types = Set() + if canMeasureSteps { types.insert(.steps) } + if canMeasureDistance { types.insert(.distance) } + if canMeasureFloors { types.insert(.floorsClimbed) } + if canMeasureHeartRate { + types.insert(.heartRate) + types.insert(.restingHeartRate) + } + if canMeasureBloodPressure { + types.insert(.bloodPressureSystolic) + types.insert(.bloodPressureDiastolic) + } + if canMeasureBloodOxygen { types.insert(.bloodOxygen) } + if canMeasureSleep { types.insert(.sleep) } + return types + } +} + +// MARK: - Source Health Report +struct SourceHealthReport { + let source: HealthSource + let lastActivityByType: [HealthDataType: Date] + let recordCountByType: [HealthDataType: Int] + let dataGaps: [HealthDataType: [TimeWindow]] + let overallQuality: DataQuality + let checkedAt: Date + + var lastOverallActivity: Date? { + lastActivityByType.values.max() + } + + var totalRecordCount: Int { + recordCountByType.values.reduce(0, +) + } + + var hasSignificantGaps: Bool { + dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap + } +} + +// MARK: - Source Storage Manager +class SourceStorageManager { + private let userDefaults = UserDefaults.standard + private let profilesKey = "healthbridge.source.profiles" + + func saveSourceProfiles(_ profiles: [String: SourceProfile]) { + do { + let data = try JSONEncoder().encode(profiles) + userDefaults.set(data, forKey: profilesKey) + } catch { + print("Failed to save source profiles: \(error)") + } + } + + func loadSourceProfiles() -> [String: SourceProfile] { + guard let data = userDefaults.data(forKey: profilesKey) else { + return [:] + } + + do { + return try JSONDecoder().decode([String: SourceProfile].self, from: data) + } catch { + print("Failed to load source profiles: \(error)") + return [:] + } + } +} diff --git a/HealthBridge/Services/SyncCoordinator.swift b/HealthBridge/Services/SyncCoordinator.swift new file mode 100644 index 0000000..18e8258 --- /dev/null +++ b/HealthBridge/Services/SyncCoordinator.swift @@ -0,0 +1,301 @@ +import Foundation +import Combine +import UserNotifications + +// MARK: - Sync Coordinator +@MainActor +class SyncCoordinator: ObservableObject { + static let shared = SyncCoordinator() + + private let healthKitManager = HealthKitManager.shared + private let sourceManager = SourceManager.shared + private let dataReader = DataReader.shared + private let ruleEngine = RuleEngine.shared + private let mergeEngine = MergeEngine.shared + private let dataWriter = DataWriter.shared + + @Published var isSyncing = false + @Published var syncProgress: Double = 0 + @Published var lastSyncDate: Date? + @Published var lastSyncResult: SyncResult? + @Published var pendingConflicts: [Conflict] = [] + @Published var syncHistory: [SyncResult] = [] + + private let syncHistoryKey = "healthbridge.sync.history" + private let maxHistoryItems = 50 + + private init() { + loadSyncHistory() + } + + // MARK: - Main Sync + + func performSync( + for date: Date = Date(), + dataTypes: [HealthDataType] = HealthDataType.allCases + ) async throws { + guard !isSyncing else { return } + + isSyncing = true + syncProgress = 0 + defer { isSyncing = false } + + let startTime = Date() + var result = SyncResult(startedAt: startTime) + + do { + // Step 1: Refresh sources (10%) + syncProgress = 0.05 + await sourceManager.discoverSources() + syncProgress = 0.1 + + // Step 2: Detect conflicts (40%) + let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes) + result.totalConflicts = conflicts.count + syncProgress = 0.4 + + // Step 3: Process conflicts with merge engine (70%) + let operations = await mergeEngine.processConflicts(conflicts) + syncProgress = 0.7 + + let autoResolved = operations.filter { $0.status == .resolved } + let pendingManual = operations.filter { $0.status == .pendingManualReview } + + result.autoResolved = autoResolved.count + result.pendingManualReview = pendingManual.count + + // Update pending conflicts + pendingConflicts = pendingManual.map { $0.conflict } + + // Step 4: Write resolved records (90%) + let recordsToWrite = autoResolved.compactMap { $0.mergedRecord } + if !recordsToWrite.isEmpty { + let writeResult = await dataWriter.writeBatch(recordsToWrite) + result.writtenRecords = writeResult.successCount + result.writeErrors = writeResult.failureCount + } + syncProgress = 0.9 + + // Step 5: Finalize (100%) + result.completedAt = Date() + result.status = .success + syncProgress = 1.0 + + } catch { + result.status = .failed + result.error = error.localizedDescription + result.completedAt = Date() + throw error + } + + lastSyncDate = Date() + lastSyncResult = result + addToHistory(result) + + // Send notification if there are pending conflicts + if result.pendingManualReview > 0 { + await sendConflictNotification(count: result.pendingManualReview) + } + } + + // MARK: - Quick Sync + + func performQuickSync() async throws { + try await performSync( + for: Date(), + dataTypes: [.steps, .heartRate, .activeEnergy] + ) + } + + // MARK: - Sync Specific Data Type + + func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws { + try await performSync(for: date, dataTypes: [dataType]) + } + + // MARK: - Manual Conflict Resolution + + func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws { + guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else { + throw SyncError.resolutionFailed + } + + var resolvedConflict = conflict + resolvedConflict.status = .resolved + resolvedConflict.resolution = resolution + resolvedConflict.resolvedAt = Date() + + let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution) + + // Write the record + _ = try await dataWriter.writeRecord(mergedRecord) + + // Remove from pending + pendingConflicts.removeAll { $0.id == conflict.id } + } + + func ignoreConflict(_ conflict: Conflict) { + pendingConflicts.removeAll { $0.id == conflict.id } + } + + // MARK: - Sync History + + private func loadSyncHistory() { + guard let data = UserDefaults.standard.data(forKey: syncHistoryKey), + let history = try? JSONDecoder().decode([SyncResult].self, from: data) else { + return + } + syncHistory = history + } + + private func addToHistory(_ result: SyncResult) { + syncHistory.insert(result, at: 0) + if syncHistory.count > maxHistoryItems { + syncHistory = Array(syncHistory.prefix(maxHistoryItems)) + } + saveSyncHistory() + } + + private func saveSyncHistory() { + guard let data = try? JSONEncoder().encode(syncHistory) else { return } + UserDefaults.standard.set(data, forKey: syncHistoryKey) + } + + func clearHistory() { + syncHistory.removeAll() + UserDefaults.standard.removeObject(forKey: syncHistoryKey) + } + + // MARK: - Notifications + + private func sendConflictNotification(count: Int) async { + let center = UNUserNotificationCenter.current() + + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized else { return } + + let content = UNMutableNotificationContent() + content.title = "HealthBridge" + content.body = count == 1 + ? "1 Konflikt erfordert Ihre Aufmerksamkeit" + : "\(count) Konflikte erfordern Ihre Aufmerksamkeit" + content.sound = .default + content.badge = NSNumber(value: count) + + let request = UNNotificationRequest( + identifier: "healthbridge.conflicts", + content: content, + trigger: nil + ) + + try? await center.add(request) + } + + func requestNotificationPermission() async -> Bool { + let center = UNUserNotificationCenter.current() + + do { + return try await center.requestAuthorization(options: [.alert, .sound, .badge]) + } catch { + return false + } + } + + // MARK: - Statistics + + var todayStats: TodayStats { + let today = Calendar.current.startOfDay(for: Date()) + let todaySyncs = syncHistory.filter { + Calendar.current.isDate($0.startedAt, inSameDayAs: today) + } + + return TodayStats( + syncCount: todaySyncs.count, + totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts }, + autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved }, + pendingManual: pendingConflicts.count, + lastSync: lastSyncDate + ) + } +} + +// MARK: - Supporting Types + +struct SyncResult: Identifiable, Codable { + let id = UUID() + let startedAt: Date + var completedAt: Date? + var status: SyncStatus = .inProgress + var totalConflicts = 0 + var autoResolved = 0 + var pendingManualReview = 0 + var writtenRecords = 0 + var writeErrors = 0 + var error: String? + + enum SyncStatus: String, Codable { + case inProgress = "in_progress" + case success = "success" + case partialSuccess = "partial_success" + case failed = "failed" + } + + var duration: TimeInterval? { + guard let completed = completedAt else { return nil } + return completed.timeIntervalSince(startedAt) + } + + var formattedDuration: String { + guard let duration = duration else { return "–" } + if duration < 1 { + return "< 1s" + } + return String(format: "%.1fs", duration) + } + + var successRate: Double { + guard totalConflicts > 0 else { return 1.0 } + return Double(autoResolved) / Double(totalConflicts) + } +} + +struct TodayStats { + let syncCount: Int + let totalConflicts: Int + let autoResolved: Int + let pendingManual: Int + let lastSync: Date? + + var resolutionRate: Double { + guard totalConflicts > 0 else { return 1.0 } + return Double(autoResolved) / Double(totalConflicts) + } + + var formattedLastSync: String { + guard let date = lastSync else { return "Nie" } + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +enum SyncError: LocalizedError { + case notAuthorized + case syncInProgress + case resolutionFailed + case writeFailed + + var errorDescription: String? { + switch self { + case .notAuthorized: + return "Keine Berechtigung für HealthKit" + case .syncInProgress: + return "Synchronisierung läuft bereits" + case .resolutionFailed: + return "Konfliktauflösung fehlgeschlagen" + case .writeFailed: + return "Schreiben der Daten fehlgeschlagen" + } + } +} diff --git a/HealthBridge/Utils/Extensions.swift b/HealthBridge/Utils/Extensions.swift new file mode 100644 index 0000000..cd9c8e0 --- /dev/null +++ b/HealthBridge/Utils/Extensions.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftUI + +// MARK: - Date Extensions +extension Date { + var startOfDay: Date { + Calendar.current.startOfDay(for: self) + } + + var endOfDay: Date { + Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1) + } + + var isToday: Bool { + Calendar.current.isDateInToday(self) + } + + var isYesterday: Bool { + Calendar.current.isDateInYesterday(self) + } + + func formatted(style: DateFormatter.Style) -> String { + let formatter = DateFormatter() + formatter.dateStyle = style + formatter.timeStyle = .none + return formatter.string(from: self) + } + + func formattedTime() -> String { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: self) + } + + func formattedRelative() -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } + + func adding(days: Int) -> Date { + Calendar.current.date(byAdding: .day, value: days, to: self)! + } + + func adding(hours: Int) -> Date { + Calendar.current.date(byAdding: .hour, value: hours, to: self)! + } + + func adding(minutes: Int) -> Date { + Calendar.current.date(byAdding: .minute, value: minutes, to: self)! + } +} + +// MARK: - Double Extensions +extension Double { + func formatted(decimals: Int = 1) -> String { + String(format: "%.\(decimals)f", self) + } + + var formattedAsInteger: String { + String(format: "%.0f", self) + } + + var formattedAsPercentage: String { + String(format: "%.1f%%", self * 100) + } +} + +// MARK: - Array Extensions +extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0.. Color { + switch severity { + case .minor: return .green + case .moderate: return .yellow + case .significant: return .orange + case .major: return .red + } + } + + static func forQuality(_ quality: DataQuality) -> Color { + switch quality { + case .complete: return .green + case .partial: return .yellow + case .missing: return .gray + case .invalid: return .red + } + } +} + +// MARK: - View Extensions +extension View { + func cardStyle() -> some View { + self + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, y: 2) + } + + func sectionHeader(_ title: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + self + } + } +} + +// MARK: - Binding Extensions +extension Binding { + func onChange(_ handler: @escaping (Value) -> Void) -> Binding { + Binding( + get: { self.wrappedValue }, + set: { newValue in + self.wrappedValue = newValue + handler(newValue) + } + ) + } +} + +// MARK: - Optional Extensions +extension Optional where Wrapped == String { + var orEmpty: String { + self ?? "" + } + + var isNilOrEmpty: Bool { + self?.isEmpty ?? true + } +} + +// MARK: - Collection Extensions +extension Collection { + var isNotEmpty: Bool { + !isEmpty + } +} diff --git a/HealthBridge/Utils/NotificationManager.swift b/HealthBridge/Utils/NotificationManager.swift new file mode 100644 index 0000000..fe0a74a --- /dev/null +++ b/HealthBridge/Utils/NotificationManager.swift @@ -0,0 +1,185 @@ +import Foundation +import UserNotifications + +@MainActor +class NotificationManager: ObservableObject { + static let shared = NotificationManager() + + @Published var isAuthorized = false + @Published var pendingNotifications: [String] = [] + + private let center = UNUserNotificationCenter.current() + + private init() { + Task { + await checkAuthorization() + } + } + + // MARK: - Authorization + + func requestAuthorization() async -> Bool { + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + isAuthorized = granted + return granted + } catch { + print("Notification authorization failed: \(error)") + return false + } + } + + func checkAuthorization() async { + let settings = await center.notificationSettings() + isAuthorized = settings.authorizationStatus == .authorized + } + + // MARK: - Conflict Notifications + + func sendConflictNotification(count: Int) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = "HealthBridge" + + if count == 1 { + content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit" + } else { + content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit" + } + + content.sound = .default + content.badge = NSNumber(value: count) + content.categoryIdentifier = "CONFLICT" + + let request = UNNotificationRequest( + identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await center.add(request) + } catch { + print("Failed to send notification: \(error)") + } + } + + // MARK: - Sync Notifications + + func sendSyncCompleteNotification( + conflictsResolved: Int, + pendingConflicts: Int + ) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = "Sync abgeschlossen" + + if pendingConflicts > 0 { + content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen" + } else { + content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst" + } + + content.sound = .default + content.categoryIdentifier = "SYNC_COMPLETE" + + let request = UNNotificationRequest( + identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + + do { + try await center.add(request) + } catch { + print("Failed to send notification: \(error)") + } + } + + // MARK: - Scheduled Notifications + + func scheduleReminder(at hour: Int, minute: Int) async { + guard isAuthorized else { return } + + let content = UNMutableNotificationContent() + content.title = "HealthBridge Erinnerung" + content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren" + content.sound = .default + + var dateComponents = DateComponents() + dateComponents.hour = hour + dateComponents.minute = minute + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + + let request = UNNotificationRequest( + identifier: "healthbridge.reminder.daily", + content: content, + trigger: trigger + ) + + do { + try await center.add(request) + } catch { + print("Failed to schedule reminder: \(error)") + } + } + + func cancelReminder() { + center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"]) + } + + // MARK: - Badge Management + + func clearBadge() async { + do { + try await center.setBadgeCount(0) + } catch { + print("Failed to clear badge: \(error)") + } + } + + func updateBadge(count: Int) async { + do { + try await center.setBadgeCount(count) + } catch { + print("Failed to update badge: \(error)") + } + } + + // MARK: - Notification Categories + + func registerCategories() { + // Conflict category with actions + let resolveAction = UNNotificationAction( + identifier: "RESOLVE_AUTO", + title: "Automatisch lösen", + options: [] + ) + + let viewAction = UNNotificationAction( + identifier: "VIEW_CONFLICTS", + title: "Anzeigen", + options: [.foreground] + ) + + let conflictCategory = UNNotificationCategory( + identifier: "CONFLICT", + actions: [resolveAction, viewAction], + intentIdentifiers: [], + options: [] + ) + + // Sync complete category + let syncCategory = UNNotificationCategory( + identifier: "SYNC_COMPLETE", + actions: [viewAction], + intentIdentifiers: [], + options: [] + ) + + center.setNotificationCategories([conflictCategory, syncCategory]) + } +} diff --git a/HealthBridge/ViewModels/DashboardViewModel.swift b/HealthBridge/ViewModels/DashboardViewModel.swift new file mode 100644 index 0000000..ec945a1 --- /dev/null +++ b/HealthBridge/ViewModels/DashboardViewModel.swift @@ -0,0 +1,103 @@ +import Foundation +import Combine + +@MainActor +class DashboardViewModel: ObservableObject { + private let syncCoordinator = SyncCoordinator.shared + private let dataReader = DataReader.shared + private let sourceManager = SourceManager.shared + + @Published var dailySummary: DailySummary? + @Published var isLoading = false + @Published var selectedDate = Date() + @Published var errorMessage: String? + + private var cancellables = Set() + + init() { + setupBindings() + } + + private func setupBindings() { + $selectedDate + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] date in + Task { + await self?.loadData(for: date) + } + } + .store(in: &cancellables) + } + + func loadData(for date: Date = Date()) async { + isLoading = true + errorMessage = nil + + do { + dailySummary = try await dataReader.fetchDailySummary(for: date) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func performSync() async { + do { + try await syncCoordinator.performSync(for: selectedDate) + await loadData(for: selectedDate) + } catch { + errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)" + } + } + + func refresh() async { + await sourceManager.discoverSources() + await loadData(for: selectedDate) + } + + var syncStatus: SyncStatus { + if syncCoordinator.isSyncing { + return .syncing + } else if let lastSync = syncCoordinator.lastSyncDate { + let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600 + if hoursSinceSync < 1 { + return .synced + } else if hoursSinceSync < 24 { + return .stale + } else { + return .veryStale + } + } else { + return .neverSynced + } + } + + enum SyncStatus { + case syncing + case synced + case stale + case veryStale + case neverSynced + + var description: String { + switch self { + case .syncing: return "Synchronisiere..." + case .synced: return "Synchronisiert" + case .stale: return "Sync empfohlen" + case .veryStale: return "Sync überfällig" + case .neverSynced: return "Nie synchronisiert" + } + } + + var icon: String { + switch self { + case .syncing: return "arrow.triangle.2.circlepath" + case .synced: return "checkmark.circle.fill" + case .stale: return "exclamationmark.circle" + case .veryStale: return "exclamationmark.triangle" + case .neverSynced: return "xmark.circle" + } + } + } +} diff --git a/HealthBridge/Views/Components/HealthChart.swift b/HealthBridge/Views/Components/HealthChart.swift new file mode 100644 index 0000000..082cf66 --- /dev/null +++ b/HealthBridge/Views/Components/HealthChart.swift @@ -0,0 +1,241 @@ +import SwiftUI +import Charts + +struct HealthChart: View { + let dataType: HealthDataType + let data: [ChartDataPoint] + let showConflicts: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: dataType.icon) + .foregroundStyle(.blue) + Text(dataType.displayName) + .font(.headline) + Spacer() + if let latest = data.last { + Text(latest.formattedValue) + .font(.title3) + .fontWeight(.semibold) + Text(dataType.unit) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if #available(iOS 16.0, *) { + Chart(data) { point in + LineMark( + x: .value("Zeit", point.date), + y: .value("Wert", point.value) + ) + .foregroundStyle(.blue) + + PointMark( + x: .value("Zeit", point.date), + y: .value("Wert", point.value) + ) + .foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue) + .symbolSize(point.hasConflict && showConflicts ? 100 : 50) + } + .frame(height: 150) + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 4)) { value in + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .leading) + } + } else { + // Fallback for iOS 15 + simpleChart + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var simpleChart: some View { + GeometryReader { geometry in + let maxValue = data.map { $0.value }.max() ?? 1 + let minValue = data.map { $0.value }.min() ?? 0 + let range = maxValue - minValue + + Path { path in + guard !data.isEmpty else { return } + + let xStep = geometry.size.width / CGFloat(max(1, data.count - 1)) + + for (index, point) in data.enumerated() { + let x = CGFloat(index) * xStep + let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5 + let y = geometry.size.height * (1 - normalizedY) + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(Color.blue, lineWidth: 2) + } + .frame(height: 150) + } +} + +struct ChartDataPoint: Identifiable { + let id = UUID() + let date: Date + let value: Double + let hasConflict: Bool + let sourceId: String? + + var formattedValue: String { + if value == floor(value) { + return String(format: "%.0f", value) + } + return String(format: "%.1f", value) + } +} + +// MARK: - Blood Pressure Chart +struct BloodPressureChart: View { + let data: [BloodPressurePoint] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "drop.fill") + .foregroundStyle(.red) + Text("Blutdruck") + .font(.headline) + Spacer() + if let latest = data.last { + Text("\(Int(latest.systolic))/\(Int(latest.diastolic))") + .font(.title3) + .fontWeight(.semibold) + Text("mmHg") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if #available(iOS 16.0, *) { + Chart(data) { point in + // Systolic + LineMark( + x: .value("Zeit", point.date), + y: .value("Systolisch", point.systolic) + ) + .foregroundStyle(.red) + + // Diastolic + LineMark( + x: .value("Zeit", point.date), + y: .value("Diastolisch", point.diastolic) + ) + .foregroundStyle(.blue) + } + .frame(height: 150) + .chartYScale(domain: 40...180) + .chartLegend(position: .bottom) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +struct BloodPressurePoint: Identifiable { + let id = UUID() + let date: Date + let systolic: Double + let diastolic: Double + let classification: BloodPressureHandler.BloodPressureClassification +} + +// MARK: - Summary Ring +struct SummaryRing: View { + let progress: Double + let color: Color + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 4) { + ZStack { + Circle() + .stroke(color.opacity(0.2), lineWidth: 8) + + Circle() + .trim(from: 0, to: min(progress, 1.0)) + .stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + + VStack(spacing: 2) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(color) + Text(value) + .font(.caption2) + .fontWeight(.semibold) + } + } + .frame(width: 60, height: 60) + + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +#Preview { + VStack(spacing: 20) { + HealthChart( + dataType: .steps, + data: (0..<24).map { hour in + ChartDataPoint( + date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!, + value: Double.random(in: 0...1000), + hasConflict: hour % 5 == 0, + sourceId: nil + ) + }, + showConflicts: true + ) + + HStack(spacing: 20) { + SummaryRing( + progress: 0.75, + color: .blue, + icon: "figure.walk", + value: "7.5k", + label: "Schritte" + ) + + SummaryRing( + progress: 0.5, + color: .red, + icon: "heart.fill", + value: "72", + label: "Puls" + ) + + SummaryRing( + progress: 1.0, + color: .green, + icon: "checkmark.circle", + value: "100%", + label: "Synced" + ) + } + } + .padding() +} diff --git a/HealthBridge/Views/ConflictsView.swift b/HealthBridge/Views/ConflictsView.swift new file mode 100644 index 0000000..1d22d55 --- /dev/null +++ b/HealthBridge/Views/ConflictsView.swift @@ -0,0 +1,348 @@ +import SwiftUI + +struct ConflictsView: View { + @EnvironmentObject var syncCoordinator: SyncCoordinator + @State private var selectedConflict: Conflict? + @State private var showingDetail = false + + var body: some View { + NavigationStack { + Group { + if syncCoordinator.pendingConflicts.isEmpty { + emptyState + } else { + conflictsList + } + } + .navigationTitle("Konflikte") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Alle automatisch lösen") { + Task { await resolveAllAuto() } + } + Button("Alle ignorieren", role: .destructive) { + ignoreAll() + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(syncCoordinator.pendingConflicts.isEmpty) + } + } + .sheet(item: $selectedConflict) { conflict in + ConflictDetailView(conflict: conflict) { selectedReadingId in + Task { + await resolveConflict(conflict, selectedReadingId: selectedReadingId) + } + } + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + + Text("Keine Konflikte") + .font(.title2) + .fontWeight(.semibold) + + Text("Alle Ihre Gesundheitsdaten sind synchronisiert") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } + + // MARK: - Conflicts List + + private var conflictsList: some View { + List { + ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in + Section(dataType.displayName) { + ForEach(groupedConflicts[dataType] ?? []) { conflict in + ConflictRow(conflict: conflict) + .contentShape(Rectangle()) + .onTapGesture { + selectedConflict = conflict + } + } + } + } + } + .listStyle(.insetGrouped) + } + + private var groupedConflicts: [HealthDataType: [Conflict]] { + Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType }) + } + + // MARK: - Actions + + private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async { + do { + try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId) + selectedConflict = nil + } catch { + print("Failed to resolve conflict: \(error)") + } + } + + private func resolveAllAuto() async { + for conflict in syncCoordinator.pendingConflicts { + if let primaryReading = conflict.primarySourceReading { + try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id) + } + } + } + + private func ignoreAll() { + for conflict in syncCoordinator.pendingConflicts { + syncCoordinator.ignoreConflict(conflict) + } + } +} + +// MARK: - Conflict Row + +struct ConflictRow: View { + let conflict: Conflict + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: conflict.dataType.icon) + .foregroundStyle(.blue) + + Text(conflict.timeWindow.formattedRange) + .font(.headline) + + Spacer() + + severityBadge + } + + HStack(spacing: 16) { + ForEach(conflict.readings.prefix(3)) { reading in + VStack(alignment: .leading, spacing: 2) { + Text(reading.sourceName) + .font(.caption) + .foregroundStyle(.secondary) + Text(reading.formattedValue) + .font(.subheadline) + .fontWeight(.medium) + } + } + } + + if conflict.readings.count > 3 { + Text("+\(conflict.readings.count - 3) weitere") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private var severityBadge: some View { + Text(conflict.severity.displayName) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(severityColor.opacity(0.2)) + .foregroundStyle(severityColor) + .clipShape(Capsule()) + } + + private var severityColor: Color { + switch conflict.severity { + case .minor: return .green + case .moderate: return .yellow + case .significant: return .orange + case .major: return .red + } + } +} + +// MARK: - Conflict Detail View + +struct ConflictDetailView: View { + let conflict: Conflict + let onResolve: (UUID) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var selectedReadingId: UUID? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Header + headerSection + + // Readings + readingsSection + + // Difference Info + differenceSection + + Spacer() + } + .padding() + } + .navigationTitle("Konflikt lösen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Auswählen") { + if let id = selectedReadingId { + onResolve(id) + } + } + .disabled(selectedReadingId == nil) + } + } + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: 8) { + Image(systemName: conflict.dataType.icon) + .font(.largeTitle) + .foregroundStyle(.blue) + + Text(conflict.dataType.displayName) + .font(.title2) + .fontWeight(.semibold) + + Text(conflict.timeWindow.formattedDate) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(conflict.timeWindow.formattedRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + } + + // MARK: - Readings + + private var readingsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Quellen") + .font(.headline) + + ForEach(conflict.readings) { reading in + ReadingCard( + reading: reading, + isSelected: selectedReadingId == reading.id, + dataType: conflict.dataType + ) + .onTapGesture { + selectedReadingId = reading.id + } + } + } + } + + // MARK: - Difference + + private var differenceSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Analyse") + .font(.headline) + + HStack { + VStack(alignment: .leading) { + Text("Differenz") + .font(.caption) + .foregroundStyle(.secondary) + Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit)) + .font(.title3) + .fontWeight(.medium) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Prozentual") + .font(.caption) + .foregroundStyle(.secondary) + Text(String(format: "%.1f%%", conflict.percentageDifference)) + .font(.title3) + .fontWeight(.medium) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} + +// MARK: - Reading Card + +struct ReadingCard: View { + let reading: SourceReading + let isSelected: Bool + let dataType: HealthDataType + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: reading.sourceCategory.icon) + .foregroundStyle(.blue) + Text(reading.sourceName) + .font(.headline) + } + + Text(reading.sourceCategory.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(reading.formattedValue) + .font(.title2) + .fontWeight(.semibold) + Text(dataType.unit) + .font(.caption) + .foregroundStyle(.secondary) + } + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(isSelected ? .blue : .secondary) + } + .padding() + .background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + } +} + +#Preview { + ConflictsView() + .environmentObject(SyncCoordinator.shared) +} diff --git a/HealthBridge/Views/ContentView.swift b/HealthBridge/Views/ContentView.swift new file mode 100644 index 0000000..d73425f --- /dev/null +++ b/HealthBridge/Views/ContentView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var syncCoordinator: SyncCoordinator + + var body: some View { + TabView(selection: $appState.selectedTab) { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "chart.bar.fill") + } + .tag(AppState.Tab.dashboard) + + ConflictsView() + .tabItem { + Label("Konflikte", systemImage: "arrow.triangle.2.circlepath") + } + .tag(AppState.Tab.conflicts) + .badge(syncCoordinator.pendingConflicts.count) + + RulesView() + .tabItem { + Label("Regeln", systemImage: "slider.horizontal.3") + } + .tag(AppState.Tab.rules) + + SourcesView() + .tabItem { + Label("Quellen", systemImage: "antenna.radiowaves.left.and.right") + } + .tag(AppState.Tab.sources) + } + .tint(.blue) + } +} + +#Preview { + ContentView() + .environmentObject(AppState()) + .environmentObject(SyncCoordinator.shared) +} diff --git a/HealthBridge/Views/DashboardView.swift b/HealthBridge/Views/DashboardView.swift new file mode 100644 index 0000000..1f01a10 --- /dev/null +++ b/HealthBridge/Views/DashboardView.swift @@ -0,0 +1,379 @@ +import SwiftUI + +struct DashboardView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var syncCoordinator: SyncCoordinator + @StateObject private var dataReader = DataReader.shared + + @State private var dailySummary: DailySummary? + @State private var isLoading = false + @State private var selectedDate = Date() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Sync Status Card + syncStatusCard + + // Date Picker + datePicker + + // Health Metrics + if let summary = dailySummary { + healthMetricsGrid(summary: summary) + } else if isLoading { + loadingView + } else { + emptyStateView + } + + // Pending Conflicts Alert + if !syncCoordinator.pendingConflicts.isEmpty { + pendingConflictsCard + } + + // Recent Sync History + recentSyncHistory + } + .padding() + } + .navigationTitle("HealthBridge") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await performSync() } + } label: { + if syncCoordinator.isSyncing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(syncCoordinator.isSyncing) + } + } + .refreshable { + await loadData() + } + .task { + await loadData() + } + .onChange(of: selectedDate) { + Task { await loadData() } + } + } + } + + // MARK: - Sync Status Card + + private var syncStatusCard: some View { + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Heute") + .font(.headline) + Text(syncCoordinator.todayStats.formattedLastSync) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if syncCoordinator.isSyncing { + VStack(alignment: .trailing) { + ProgressView(value: syncCoordinator.syncProgress) + .frame(width: 60) + Text("Synchronisiere...") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + } + } + + Divider() + + HStack(spacing: 20) { + StatItem( + value: "\(syncCoordinator.todayStats.syncCount)", + label: "Syncs", + icon: "arrow.triangle.2.circlepath" + ) + + StatItem( + value: "\(syncCoordinator.todayStats.totalConflicts)", + label: "Konflikte", + icon: "exclamationmark.triangle" + ) + + StatItem( + value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%", + label: "Gelöst", + icon: "checkmark.circle" + ) + } + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.05), radius: 8, y: 4) + } + + // MARK: - Date Picker + + private var datePicker: some View { + DatePicker( + "Datum", + selection: $selectedDate, + in: ...Date(), + displayedComponents: .date + ) + .datePickerStyle(.compact) + .padding(.horizontal) + } + + // MARK: - Health Metrics Grid + + private func healthMetricsGrid(summary: DailySummary) -> some View { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + ForEach(HealthDataType.allCases) { dataType in + if let value = summary.values[dataType], value > 0 { + HealthMetricCard( + dataType: dataType, + value: summary.formattedValue(for: dataType), + conflictCount: summary.conflictCounts[dataType] ?? 0 + ) + } + } + } + } + + // MARK: - Pending Conflicts Card + + private var pendingConflictsCard: some View { + Button { + appState.selectedTab = .conflicts + } label: { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + + VStack(alignment: .leading) { + Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen") + .font(.headline) + Text("Tippen zum Anzeigen") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .padding() + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + + // MARK: - Recent Sync History + + private var recentSyncHistory: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Letzte Synchronisierungen") + .font(.headline) + + ForEach(syncCoordinator.syncHistory.prefix(5)) { result in + SyncHistoryRow(result: result) + } + + if syncCoordinator.syncHistory.isEmpty { + Text("Keine Synchronisierungen") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding() + } + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.05), radius: 8, y: 4) + } + + // MARK: - Loading & Empty States + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + Text("Lade Daten...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "heart.text.square") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("Keine Daten verfügbar") + .font(.headline) + Text("Synchronisieren Sie, um Daten zu laden") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + // MARK: - Actions + + private func loadData() async { + isLoading = true + defer { isLoading = false } + + do { + dailySummary = try await dataReader.fetchDailySummary(for: selectedDate) + } catch { + print("Failed to load data: \(error)") + } + } + + private func performSync() async { + do { + try await syncCoordinator.performSync(for: selectedDate) + await loadData() + } catch { + print("Sync failed: \(error)") + } + } +} + +// MARK: - Supporting Views + +struct StatItem: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.title3) + .fontWeight(.semibold) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +struct HealthMetricCard: View { + let dataType: HealthDataType + let value: String + let conflictCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: dataType.icon) + .foregroundStyle(.blue) + Spacer() + if conflictCount > 0 { + Text("\(conflictCount)") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.2)) + .clipShape(Capsule()) + } + } + + Text(value) + .font(.title2) + .fontWeight(.semibold) + + Text(dataType.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, y: 2) + } +} + +struct SyncHistoryRow: View { + let result: SyncResult + + var body: some View { + HStack { + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + + VStack(alignment: .leading, spacing: 2) { + Text(formattedDate) + .font(.subheadline) + Text("\(result.autoResolved)/\(result.totalConflicts) gelöst") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(result.formattedDuration) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + private var statusIcon: String { + switch result.status { + case .success: return "checkmark.circle.fill" + case .partialSuccess: return "exclamationmark.circle.fill" + case .failed: return "xmark.circle.fill" + case .inProgress: return "arrow.clockwise" + } + } + + private var statusColor: Color { + switch result.status { + case .success: return .green + case .partialSuccess: return .orange + case .failed: return .red + case .inProgress: return .blue + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: result.startedAt) + } +} + +#Preview { + DashboardView() + .environmentObject(AppState()) + .environmentObject(SyncCoordinator.shared) +} diff --git a/HealthBridge/Views/RulesView.swift b/HealthBridge/Views/RulesView.swift new file mode 100644 index 0000000..ccf43f6 --- /dev/null +++ b/HealthBridge/Views/RulesView.swift @@ -0,0 +1,252 @@ +import SwiftUI + +struct RulesView: View { + @StateObject private var ruleEngine = RuleEngine.shared + @StateObject private var sourceManager = SourceManager.shared + @State private var selectedDataType: HealthDataType? + @State private var showingResetConfirmation = false + + var body: some View { + NavigationStack { + List { + Section { + Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.") + .font(.caption) + .foregroundStyle(.secondary) + } + + ForEach(HealthDataType.allCases) { dataType in + RuleRow( + dataType: dataType, + rule: ruleEngine.getRule(for: dataType) + ) + .contentShape(Rectangle()) + .onTapGesture { + selectedDataType = dataType + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Regeln") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Alle zurücksetzen", role: .destructive) { + showingResetConfirmation = true + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .sheet(item: $selectedDataType) { dataType in + RuleEditorView(dataType: dataType) + } + .confirmationDialog( + "Alle Regeln zurücksetzen?", + isPresented: $showingResetConfirmation, + titleVisibility: .visible + ) { + Button("Zurücksetzen", role: .destructive) { + ruleEngine.resetAllToDefaults() + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.") + } + } + } +} + +// MARK: - Rule Row + +struct RuleRow: View { + let dataType: HealthDataType + let rule: MergeRule + + var body: some View { + HStack { + Image(systemName: dataType.icon) + .foregroundStyle(.blue) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(dataType.displayName) + .font(.headline) + + HStack(spacing: 8) { + Image(systemName: rule.strategy.icon) + .font(.caption) + Text(rule.strategy.displayName) + .font(.caption) + } + .foregroundStyle(.secondary) + } + + Spacer() + + if !rule.autoApply { + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.caption) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } +} + +// MARK: - Rule Editor View + +struct RuleEditorView: View { + let dataType: HealthDataType + @Environment(\.dismiss) private var dismiss + @StateObject private var ruleEngine = RuleEngine.shared + @StateObject private var sourceManager = SourceManager.shared + + @State private var selectedStrategy: MergeStrategy + @State private var autoApply: Bool + @State private var primarySourceId: String? + @State private var thresholdForManualReview: Double? + @State private var useThreshold: Bool + + init(dataType: HealthDataType) { + self.dataType = dataType + let rule = RuleEngine.shared.getRule(for: dataType) + _selectedStrategy = State(initialValue: rule.strategy) + _autoApply = State(initialValue: rule.autoApply) + _primarySourceId = State(initialValue: rule.primarySourceId) + _thresholdForManualReview = State(initialValue: rule.thresholdForManualReview) + _useThreshold = State(initialValue: rule.thresholdForManualReview != nil) + } + + var body: some View { + NavigationStack { + Form { + // Data Type Info + Section { + HStack { + Image(systemName: dataType.icon) + .font(.title2) + .foregroundStyle(.blue) + VStack(alignment: .leading) { + Text(dataType.displayName) + .font(.headline) + Text(dataType.unit) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // Strategy Selection + Section("Strategie") { + Picker("Merge-Strategie", selection: $selectedStrategy) { + ForEach(MergeStrategy.allCases) { strategy in + Label(strategy.displayName, systemImage: strategy.icon) + .tag(strategy) + } + } + .pickerStyle(.navigationLink) + + Text(selectedStrategy.description) + .font(.caption) + .foregroundStyle(.secondary) + } + + // Primary Source (for exclusive/priority) + if selectedStrategy == .exclusive || selectedStrategy == .priority { + Section("Primäre Quelle") { + let sources = sourceManager.sources.filter { + $0.supportedDataTypes.contains(dataType) + } + + if sources.isEmpty { + Text("Keine Quellen für diesen Datentyp") + .foregroundStyle(.secondary) + } else { + Picker("Quelle", selection: $primarySourceId) { + Text("Automatisch").tag(nil as String?) + ForEach(sources) { source in + Text(source.displayName).tag(source.bundleIdentifier as String?) + } + } + } + } + } + + // Auto Apply + Section("Automatisierung") { + Toggle("Automatisch anwenden", isOn: $autoApply) + + if autoApply { + Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold) + + if useThreshold { + VStack(alignment: .leading) { + Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen") + .font(.caption) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { thresholdForManualReview ?? 20 }, + set: { thresholdForManualReview = $0 } + ), + in: 5...50, + step: 5 + ) + } + } + } + } + + // Reset + Section { + Button("Auf Standard zurücksetzen", role: .destructive) { + let defaultRule = MergeRule.defaultRule(for: dataType) + selectedStrategy = defaultRule.strategy + autoApply = defaultRule.autoApply + primarySourceId = defaultRule.primarySourceId + thresholdForManualReview = defaultRule.thresholdForManualReview + useThreshold = defaultRule.thresholdForManualReview != nil + } + } + } + .navigationTitle("Regel bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Speichern") { + saveRule() + dismiss() + } + } + } + } + } + + private func saveRule() { + let rule = MergeRule( + dataType: dataType, + strategy: selectedStrategy, + primarySourceId: primarySourceId, + autoApply: autoApply, + thresholdForManualReview: useThreshold ? thresholdForManualReview : nil + ) + ruleEngine.setRule(rule, for: dataType) + } +} + +#Preview { + RulesView() +} diff --git a/HealthBridge/Views/SettingsView.swift b/HealthBridge/Views/SettingsView.swift new file mode 100644 index 0000000..c7a26dc --- /dev/null +++ b/HealthBridge/Views/SettingsView.swift @@ -0,0 +1,218 @@ +import SwiftUI + +struct SettingsView: View { + @AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true + @AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15 + @AppStorage("notificationsEnabled") private var notificationsEnabled = true + @AppStorage("notifyOnConflict") private var notifyOnConflict = true + @AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false + @AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true + + @StateObject private var syncCoordinator = SyncCoordinator.shared + @StateObject private var healthKitManager = HealthKitManager.shared + + @State private var showingClearDataConfirmation = false + @State private var showingExportSheet = false + + var body: some View { + NavigationStack { + Form { + // Sync Settings + Section("Synchronisierung") { + Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled) + + if backgroundSyncEnabled { + Picker("Intervall", selection: $syncIntervalMinutes) { + Text("15 Minuten").tag(15) + Text("30 Minuten").tag(30) + Text("1 Stunde").tag(60) + Text("2 Stunden").tag(120) + } + } + + Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts) + } + + // Notification Settings + Section("Benachrichtigungen") { + Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled) + + if notificationsEnabled { + Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict) + Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete) + } + } + + // Health Status + Section("HealthKit Status") { + HStack { + Text("Autorisierung") + Spacer() + if healthKitManager.isAuthorized { + Label("Erteilt", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("Ausstehend", systemImage: "exclamationmark.circle") + .foregroundStyle(.orange) + } + } + + if !healthKitManager.isAuthorized { + Button("HealthKit-Zugriff anfordern") { + Task { + try? await healthKitManager.requestAuthorization() + } + } + } + } + + // Statistics + Section("Statistiken") { + StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)") + StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)") + StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)") + StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%") + } + + // Data Management + Section("Daten") { + Button("Sync-Verlauf exportieren") { + showingExportSheet = true + } + + Button("Sync-Verlauf löschen", role: .destructive) { + showingClearDataConfirmation = true + } + } + + // About + Section("Info") { + HStack { + Text("Version") + Spacer() + Text("1.0.0") + .foregroundStyle(.secondary) + } + + Link(destination: URL(string: "https://apple.com/health")!) { + HStack { + Text("Apple Health") + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Einstellungen") + .confirmationDialog( + "Sync-Verlauf löschen?", + isPresented: $showingClearDataConfirmation, + titleVisibility: .visible + ) { + Button("Löschen", role: .destructive) { + syncCoordinator.clearHistory() + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.") + } + .sheet(isPresented: $showingExportSheet) { + ExportView() + } + } + } +} + +// MARK: - Stat Row + +struct StatRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Export View + +struct ExportView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var syncCoordinator = SyncCoordinator.shared + + @State private var exportFormat: ExportFormat = .json + @State private var isExporting = false + + enum ExportFormat: String, CaseIterable { + case json = "JSON" + case csv = "CSV" + } + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text("Daten exportieren") + .font(.title2) + .fontWeight(.semibold) + + Picker("Format", selection: $exportFormat) { + ForEach(ExportFormat.allCases, id: \.self) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + Text("\(syncCoordinator.syncHistory.count) Sync-Einträge") + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Button { + exportData() + } label: { + Label("Exportieren", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + .disabled(isExporting || syncCoordinator.syncHistory.isEmpty) + } + .padding() + .navigationTitle("Export") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + dismiss() + } + } + } + } + } + + private func exportData() { + isExporting = true + + // In a real app, this would create and share a file + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + isExporting = false + dismiss() + } + } +} + +#Preview { + SettingsView() +} diff --git a/HealthBridge/Views/SourcesView.swift b/HealthBridge/Views/SourcesView.swift new file mode 100644 index 0000000..d32ef5e --- /dev/null +++ b/HealthBridge/Views/SourcesView.swift @@ -0,0 +1,407 @@ +import SwiftUI + +struct SourcesView: View { + @StateObject private var sourceManager = SourceManager.shared + @State private var selectedSource: HealthSource? + @State private var isRefreshing = false + + var body: some View { + NavigationStack { + Group { + if sourceManager.sources.isEmpty && !sourceManager.isDiscovering { + emptyState + } else { + sourcesList + } + } + .navigationTitle("Quellen") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await refreshSources() } + } label: { + if isRefreshing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshing) + } + } + .sheet(item: $selectedSource) { source in + SourceDetailView(source: source) + } + .task { + if sourceManager.sources.isEmpty { + await refreshSources() + } + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + + Text("Keine Quellen gefunden") + .font(.title2) + .fontWeight(.semibold) + + Text("Verbinden Sie Geräte mit Apple Health") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button { + Task { await refreshSources() } + } label: { + Label("Aktualisieren", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + .padding() + } + + // MARK: - Sources List + + private var sourcesList: some View { + List { + ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in + Section(category.displayName) { + ForEach(groupedSources[category] ?? []) { source in + SourceRow(source: source) + .contentShape(Rectangle()) + .onTapGesture { + selectedSource = source + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await refreshSources() + } + } + + private var groupedSources: [SourceCategory: [HealthSource]] { + Dictionary(grouping: sourceManager.sources, by: { $0.category }) + } + + private func refreshSources() async { + isRefreshing = true + defer { isRefreshing = false } + await sourceManager.discoverSources() + } +} + +// MARK: - Source Row + +struct SourceRow: View { + let source: HealthSource + @StateObject private var sourceManager = SourceManager.shared + + var body: some View { + HStack { + Image(systemName: source.category.icon) + .font(.title2) + .foregroundStyle(.blue) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 4) { + Text(source.displayName) + .font(.headline) + + Text("\(source.supportedDataTypes.count) Datentypen") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if let status = sourceManager.sourceHealthStatus[source.id] { + Image(systemName: status.syncStatus.icon) + .foregroundStyle(statusColor(for: status.syncStatus)) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color { + switch status { + case .recentlySynced: return .green + case .syncedToday: return .blue + case .stale: return .orange + case .veryStale, .neverSynced: return .red + } + } +} + +// MARK: - Source Detail View + +struct SourceDetailView: View { + let source: HealthSource + @Environment(\.dismiss) private var dismiss + @StateObject private var sourceManager = SourceManager.shared + @State private var healthReport: SourceHealthReport? + @State private var isLoading = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Header + headerSection + + // Capabilities + capabilitiesSection + + // Data Types + dataTypesSection + + // Health Report + if let report = healthReport { + healthReportSection(report) + } + + // Priority Settings + prioritySection + } + .padding() + } + .navigationTitle(source.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Fertig") { + dismiss() + } + } + } + .task { + await loadHealthReport() + } + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(spacing: 12) { + Image(systemName: source.category.icon) + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text(source.displayName) + .font(.title2) + .fontWeight(.semibold) + + Text(source.category.displayName) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(source.bundleIdentifier) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .padding() + } + + // MARK: - Capabilities + + private var capabilitiesSection: some View { + let capabilities = sourceManager.getSourceCapabilities(source) + + return VStack(alignment: .leading, spacing: 12) { + Text("Fähigkeiten") + .font(.headline) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) { + CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps) + CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate) + CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure) + CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen) + CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep) + CapabilityBadge(name: "GPS", available: capabilities.hasGPS) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Data Types + + private var dataTypesSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Unterstützte Datentypen") + .font(.headline) + + ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in + HStack { + Image(systemName: dataType.icon) + .foregroundStyle(.blue) + .frame(width: 24) + Text(dataType.displayName) + Spacer() + Text(dataType.unit) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Health Report + + private func healthReportSection(_ report: SourceHealthReport) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Zustand") + .font(.headline) + + HStack { + VStack(alignment: .leading) { + Text("Datensätze (24h)") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(report.totalRecordCount)") + .font(.title3) + .fontWeight(.medium) + } + + Spacer() + + VStack(alignment: .trailing) { + Text("Qualität") + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: report.overallQuality.icon) + .font(.title3) + .foregroundStyle(qualityColor(report.overallQuality)) + } + } + + if let lastActivity = report.lastOverallActivity { + HStack { + Text("Letzte Aktivität") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(formattedDate(lastActivity)) + .font(.subheadline) + } + } + + if report.hasSignificantGaps { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Datenlücken erkannt") + .font(.subheadline) + } + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Priority Section + + private var prioritySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Priorität") + .font(.headline) + + Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden") + .font(.caption) + .foregroundStyle(.secondary) + + ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in + HStack { + Text(dataType.displayName) + + Spacer() + + Stepper( + "\(sourceManager.getPriority(for: source, dataType: dataType))", + value: Binding( + get: { sourceManager.getPriority(for: source, dataType: dataType) }, + set: { sourceManager.setPriority($0, for: source, dataType: dataType) } + ), + in: 0...100, + step: 10 + ) + .frame(width: 150) + } + .padding(.vertical, 4) + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // MARK: - Helpers + + private func loadHealthReport() async { + isLoading = true + defer { isLoading = false } + healthReport = await sourceManager.getSourceHealth(source) + } + + private func qualityColor(_ quality: DataQuality) -> Color { + switch quality { + case .complete: return .green + case .partial: return .yellow + case .missing: return .gray + case .invalid: return .red + } + } + + private func formattedDate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Capability Badge + +struct CapabilityBadge: View { + let name: String + let available: Bool + + var body: some View { + HStack(spacing: 4) { + Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(available ? .green : .secondary) + Text(name) + .font(.caption) + .foregroundStyle(available ? .primary : .secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1)) + .clipShape(Capsule()) + } +} + +#Preview { + SourcesView() +}