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
253 lines
9.0 KiB
Swift
253 lines
9.0 KiB
Swift
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()
|
|
}
|