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
572 lines
18 KiB
Swift
572 lines
18 KiB
Swift
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!"
|
|
}
|
|
}
|
|
}
|
|
}
|