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
+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"
}
}
}