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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user