b953908f58
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
316 lines
9.7 KiB
Swift
316 lines
9.7 KiB
Swift
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
|
|
}
|
|
}
|