Add HealthBridge iOS app for intelligent health data synchronization

Complete implementation of a SwiftUI iOS app that serves as a "Single Source
of Truth" for health data. The app reads from all Apple Health sources,
detects conflicts between devices, merges data using configurable strategies,
and writes cleaned data back.

Features:
- Phase 1: HealthKit integration with automatic source discovery
- Phase 2: DataReader with conflict detection (time-window based)
- Phase 3: RuleEngine with 8 merge strategies (exclusive, priority, higher wins, etc.)
- Phase 4: MergeEngine for conflict resolution + DataWriter for HealthKit writes
- Phase 5: SwiftUI UI for dashboard, conflicts, rules, and sources management
- Phase 6: Background sync with configurable intervals and push notifications
- Phase 7: Complete rule editor and polished UI components

Supported data types:
- Steps, Heart Rate, Blood Pressure, SpO2, Sleep
- Distance, Floors Climbed, Active Energy, HRV, Respiratory Rate

Architecture: SourceManager -> DataReader -> RuleEngine -> MergeEngine -> DataWriter
This commit is contained in:
Claude
2025-12-25 16:59:48 +00:00
parent 0ffb1c771e
commit b953908f58
24 changed files with 6258 additions and 0 deletions
+91
View File
@@ -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
}
}
+315
View File
@@ -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
}
}
+269
View File
@@ -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)
}
}
+183
View File
@@ -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<HealthDataType>
var lastActivityDate: Date?
var userPriorities: [HealthDataType: Int]
var isEnabled: Bool
init(
id: String = UUID().uuidString,
bundleIdentifier: String,
name: String,
category: SourceCategory,
supportedDataTypes: Set<HealthDataType> = [],
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
}
+140
View File
@@ -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
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>HealthBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchScreenBackground</string>
<key>UIImageName</key>
<string>LaunchIcon</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>healthkit</string>
</array>
<!-- HealthKit -->
<key>NSHealthShareUsageDescription</key>
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.healthbridge.sync</string>
<string>com.healthbridge.cleanup</string>
</array>
</dict>
</plist>
+488
View File
@@ -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)
}
}
}
+395
View File
@@ -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<String> = []
private func loadProcessedRecords() {
if let data = UserDefaults.standard.data(forKey: processedRecordsKey),
let ids = try? JSONDecoder().decode(Set<String>.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"
}
}
}
@@ -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<HKQuantityType> {
var types = Set<HKQuantityType>()
for dataType in HealthDataType.allCases {
if let quantityType = dataType.hkQuantityType {
types.insert(quantityType)
}
}
return types
}
var allCategoryTypes: Set<HKCategoryType> {
var types = Set<HKCategoryType>()
for dataType in HealthDataType.allCases {
if let categoryType = dataType.hkCategoryType {
types.insert(categoryType)
}
}
return types
}
var allSampleTypes: Set<HKSampleType> {
var types = Set<HKSampleType>()
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<HealthDataType> {
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")
}
}
+287
View File
@@ -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"
}
}
+571
View File
@@ -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!"
}
}
}
}
+409
View File
@@ -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<HealthDataType> {
var types = Set<HealthDataType>()
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 [:]
}
}
}
+301
View File
@@ -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"
}
}
}
+153
View File
@@ -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..<Swift.min($0 + size, count)])
}
}
}
// MARK: - Color Extensions
extension Color {
static let healthBridgePrimary = Color.blue
static let healthBridgeSecondary = Color.cyan
static let healthBridgeAccent = Color.orange
static func forSeverity(_ severity: ConflictSeverity) -> 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<Value> {
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
}
}
@@ -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])
}
}
@@ -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<AnyCancellable>()
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"
}
}
}
}
@@ -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()
}
+348
View File
@@ -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)
}
+42
View File
@@ -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)
}
+379
View File
@@ -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)
}
+252
View File
@@ -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()
}
+218
View File
@@ -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()
}
+407
View File
@@ -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()
}