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,252 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RulesView: View {
|
||||
@StateObject private var ruleEngine = RuleEngine.shared
|
||||
@StateObject private var sourceManager = SourceManager.shared
|
||||
@State private var selectedDataType: HealthDataType?
|
||||
@State private var showingResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ForEach(HealthDataType.allCases) { dataType in
|
||||
RuleRow(
|
||||
dataType: dataType,
|
||||
rule: ruleEngine.getRule(for: dataType)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedDataType = dataType
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("Regeln")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("Alle zurücksetzen", role: .destructive) {
|
||||
showingResetConfirmation = true
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedDataType) { dataType in
|
||||
RuleEditorView(dataType: dataType)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Alle Regeln zurücksetzen?",
|
||||
isPresented: $showingResetConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücksetzen", role: .destructive) {
|
||||
ruleEngine.resetAllToDefaults()
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rule Row
|
||||
|
||||
struct RuleRow: View {
|
||||
let dataType: HealthDataType
|
||||
let rule: MergeRule
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: dataType.icon)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dataType.displayName)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: rule.strategy.icon)
|
||||
.font(.caption)
|
||||
Text(rule.strategy.displayName)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !rule.autoApply {
|
||||
Image(systemName: "hand.raised.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rule Editor View
|
||||
|
||||
struct RuleEditorView: View {
|
||||
let dataType: HealthDataType
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var ruleEngine = RuleEngine.shared
|
||||
@StateObject private var sourceManager = SourceManager.shared
|
||||
|
||||
@State private var selectedStrategy: MergeStrategy
|
||||
@State private var autoApply: Bool
|
||||
@State private var primarySourceId: String?
|
||||
@State private var thresholdForManualReview: Double?
|
||||
@State private var useThreshold: Bool
|
||||
|
||||
init(dataType: HealthDataType) {
|
||||
self.dataType = dataType
|
||||
let rule = RuleEngine.shared.getRule(for: dataType)
|
||||
_selectedStrategy = State(initialValue: rule.strategy)
|
||||
_autoApply = State(initialValue: rule.autoApply)
|
||||
_primarySourceId = State(initialValue: rule.primarySourceId)
|
||||
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
|
||||
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Data Type Info
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: dataType.icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading) {
|
||||
Text(dataType.displayName)
|
||||
.font(.headline)
|
||||
Text(dataType.unit)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy Selection
|
||||
Section("Strategie") {
|
||||
Picker("Merge-Strategie", selection: $selectedStrategy) {
|
||||
ForEach(MergeStrategy.allCases) { strategy in
|
||||
Label(strategy.displayName, systemImage: strategy.icon)
|
||||
.tag(strategy)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.navigationLink)
|
||||
|
||||
Text(selectedStrategy.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Primary Source (for exclusive/priority)
|
||||
if selectedStrategy == .exclusive || selectedStrategy == .priority {
|
||||
Section("Primäre Quelle") {
|
||||
let sources = sourceManager.sources.filter {
|
||||
$0.supportedDataTypes.contains(dataType)
|
||||
}
|
||||
|
||||
if sources.isEmpty {
|
||||
Text("Keine Quellen für diesen Datentyp")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("Quelle", selection: $primarySourceId) {
|
||||
Text("Automatisch").tag(nil as String?)
|
||||
ForEach(sources) { source in
|
||||
Text(source.displayName).tag(source.bundleIdentifier as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Apply
|
||||
Section("Automatisierung") {
|
||||
Toggle("Automatisch anwenden", isOn: $autoApply)
|
||||
|
||||
if autoApply {
|
||||
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
|
||||
|
||||
if useThreshold {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { thresholdForManualReview ?? 20 },
|
||||
set: { thresholdForManualReview = $0 }
|
||||
),
|
||||
in: 5...50,
|
||||
step: 5
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
Section {
|
||||
Button("Auf Standard zurücksetzen", role: .destructive) {
|
||||
let defaultRule = MergeRule.defaultRule(for: dataType)
|
||||
selectedStrategy = defaultRule.strategy
|
||||
autoApply = defaultRule.autoApply
|
||||
primarySourceId = defaultRule.primarySourceId
|
||||
thresholdForManualReview = defaultRule.thresholdForManualReview
|
||||
useThreshold = defaultRule.thresholdForManualReview != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Regel bearbeiten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Speichern") {
|
||||
saveRule()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveRule() {
|
||||
let rule = MergeRule(
|
||||
dataType: dataType,
|
||||
strategy: selectedStrategy,
|
||||
primarySourceId: primarySourceId,
|
||||
autoApply: autoApply,
|
||||
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
|
||||
)
|
||||
ruleEngine.setRule(rule, for: dataType)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RulesView()
|
||||
}
|
||||
Reference in New Issue
Block a user