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
270 lines
8.8 KiB
Swift
270 lines
8.8 KiB
Swift
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)
|
|
}
|
|
}
|