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:
Claude
2025-12-25 16:59:48 +00:00
parent 0ffb1c771e
commit b953908f58
24 changed files with 6258 additions and 0 deletions
@@ -0,0 +1,241 @@
import SwiftUI
import Charts
struct HealthChart: View {
let dataType: HealthDataType
let data: [ChartDataPoint]
let showConflicts: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Text(dataType.displayName)
.font(.headline)
Spacer()
if let latest = data.last {
Text(latest.formattedValue)
.font(.title3)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
LineMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(.blue)
PointMark(
x: .value("Zeit", point.date),
y: .value("Wert", point.value)
)
.foregroundStyle(point.hasConflict && showConflicts ? .orange : .blue)
.symbolSize(point.hasConflict && showConflicts ? 100 : 50)
}
.frame(height: 150)
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 4)) { value in
AxisValueLabel(format: .dateTime.hour())
}
}
.chartYAxis {
AxisMarks(position: .leading)
}
} else {
// Fallback for iOS 15
simpleChart
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private var simpleChart: some View {
GeometryReader { geometry in
let maxValue = data.map { $0.value }.max() ?? 1
let minValue = data.map { $0.value }.min() ?? 0
let range = maxValue - minValue
Path { path in
guard !data.isEmpty else { return }
let xStep = geometry.size.width / CGFloat(max(1, data.count - 1))
for (index, point) in data.enumerated() {
let x = CGFloat(index) * xStep
let normalizedY = range > 0 ? (point.value - minValue) / range : 0.5
let y = geometry.size.height * (1 - normalizedY)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(Color.blue, lineWidth: 2)
}
.frame(height: 150)
}
}
struct ChartDataPoint: Identifiable {
let id = UUID()
let date: Date
let value: Double
let hasConflict: Bool
let sourceId: String?
var formattedValue: String {
if value == floor(value) {
return String(format: "%.0f", value)
}
return String(format: "%.1f", value)
}
}
// MARK: - Blood Pressure Chart
struct BloodPressureChart: View {
let data: [BloodPressurePoint]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "drop.fill")
.foregroundStyle(.red)
Text("Blutdruck")
.font(.headline)
Spacer()
if let latest = data.last {
Text("\(Int(latest.systolic))/\(Int(latest.diastolic))")
.font(.title3)
.fontWeight(.semibold)
Text("mmHg")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if #available(iOS 16.0, *) {
Chart(data) { point in
// Systolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Systolisch", point.systolic)
)
.foregroundStyle(.red)
// Diastolic
LineMark(
x: .value("Zeit", point.date),
y: .value("Diastolisch", point.diastolic)
)
.foregroundStyle(.blue)
}
.frame(height: 150)
.chartYScale(domain: 40...180)
.chartLegend(position: .bottom)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
struct BloodPressurePoint: Identifiable {
let id = UUID()
let date: Date
let systolic: Double
let diastolic: Double
let classification: BloodPressureHandler.BloodPressureClassification
}
// MARK: - Summary Ring
struct SummaryRing: View {
let progress: Double
let color: Color
let icon: String
let value: String
let label: String
var body: some View {
VStack(spacing: 4) {
ZStack {
Circle()
.stroke(color.opacity(0.2), lineWidth: 8)
Circle()
.trim(from: 0, to: min(progress, 1.0))
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 2) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(color)
Text(value)
.font(.caption2)
.fontWeight(.semibold)
}
}
.frame(width: 60, height: 60)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
#Preview {
VStack(spacing: 20) {
HealthChart(
dataType: .steps,
data: (0..<24).map { hour in
ChartDataPoint(
date: Calendar.current.date(byAdding: .hour, value: hour, to: Calendar.current.startOfDay(for: Date()))!,
value: Double.random(in: 0...1000),
hasConflict: hour % 5 == 0,
sourceId: nil
)
},
showConflicts: true
)
HStack(spacing: 20) {
SummaryRing(
progress: 0.75,
color: .blue,
icon: "figure.walk",
value: "7.5k",
label: "Schritte"
)
SummaryRing(
progress: 0.5,
color: .red,
icon: "heart.fill",
value: "72",
label: "Puls"
)
SummaryRing(
progress: 1.0,
color: .green,
icon: "checkmark.circle",
value: "100%",
label: "Synced"
)
}
}
.padding()
}
+348
View File
@@ -0,0 +1,348 @@
import SwiftUI
struct ConflictsView: View {
@EnvironmentObject var syncCoordinator: SyncCoordinator
@State private var selectedConflict: Conflict?
@State private var showingDetail = false
var body: some View {
NavigationStack {
Group {
if syncCoordinator.pendingConflicts.isEmpty {
emptyState
} else {
conflictsList
}
}
.navigationTitle("Konflikte")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle automatisch lösen") {
Task { await resolveAllAuto() }
}
Button("Alle ignorieren", role: .destructive) {
ignoreAll()
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(syncCoordinator.pendingConflicts.isEmpty)
}
}
.sheet(item: $selectedConflict) { conflict in
ConflictDetailView(conflict: conflict) { selectedReadingId in
Task {
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
}
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Keine Konflikte")
.font(.title2)
.fontWeight(.semibold)
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
// MARK: - Conflicts List
private var conflictsList: some View {
List {
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
Section(dataType.displayName) {
ForEach(groupedConflicts[dataType] ?? []) { conflict in
ConflictRow(conflict: conflict)
.contentShape(Rectangle())
.onTapGesture {
selectedConflict = conflict
}
}
}
}
}
.listStyle(.insetGrouped)
}
private var groupedConflicts: [HealthDataType: [Conflict]] {
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
}
// MARK: - Actions
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
do {
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
selectedConflict = nil
} catch {
print("Failed to resolve conflict: \(error)")
}
}
private func resolveAllAuto() async {
for conflict in syncCoordinator.pendingConflicts {
if let primaryReading = conflict.primarySourceReading {
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
}
}
}
private func ignoreAll() {
for conflict in syncCoordinator.pendingConflicts {
syncCoordinator.ignoreConflict(conflict)
}
}
}
// MARK: - Conflict Row
struct ConflictRow: View {
let conflict: Conflict
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: conflict.dataType.icon)
.foregroundStyle(.blue)
Text(conflict.timeWindow.formattedRange)
.font(.headline)
Spacer()
severityBadge
}
HStack(spacing: 16) {
ForEach(conflict.readings.prefix(3)) { reading in
VStack(alignment: .leading, spacing: 2) {
Text(reading.sourceName)
.font(.caption)
.foregroundStyle(.secondary)
Text(reading.formattedValue)
.font(.subheadline)
.fontWeight(.medium)
}
}
}
if conflict.readings.count > 3 {
Text("+\(conflict.readings.count - 3) weitere")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var severityBadge: some View {
Text(conflict.severity.displayName)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(severityColor.opacity(0.2))
.foregroundStyle(severityColor)
.clipShape(Capsule())
}
private var severityColor: Color {
switch conflict.severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
}
// MARK: - Conflict Detail View
struct ConflictDetailView: View {
let conflict: Conflict
let onResolve: (UUID) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedReadingId: UUID?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Readings
readingsSection
// Difference Info
differenceSection
Spacer()
}
.padding()
}
.navigationTitle("Konflikt lösen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Auswählen") {
if let id = selectedReadingId {
onResolve(id)
}
}
.disabled(selectedReadingId == nil)
}
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: conflict.dataType.icon)
.font(.largeTitle)
.foregroundStyle(.blue)
Text(conflict.dataType.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(conflict.timeWindow.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(conflict.timeWindow.formattedRange)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
// MARK: - Readings
private var readingsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quellen")
.font(.headline)
ForEach(conflict.readings) { reading in
ReadingCard(
reading: reading,
isSelected: selectedReadingId == reading.id,
dataType: conflict.dataType
)
.onTapGesture {
selectedReadingId = reading.id
}
}
}
}
// MARK: - Difference
private var differenceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Analyse")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Differenz")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Prozentual")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", conflict.percentageDifference))
.font(.title3)
.fontWeight(.medium)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Reading Card
struct ReadingCard: View {
let reading: SourceReading
let isSelected: Bool
let dataType: HealthDataType
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: reading.sourceCategory.icon)
.foregroundStyle(.blue)
Text(reading.sourceName)
.font(.headline)
}
Text(reading.sourceCategory.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(reading.formattedValue)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .blue : .secondary)
}
.padding()
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
}
#Preview {
ConflictsView()
.environmentObject(SyncCoordinator.shared)
}
+42
View File
@@ -0,0 +1,42 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
var body: some View {
TabView(selection: $appState.selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.bar.fill")
}
.tag(AppState.Tab.dashboard)
ConflictsView()
.tabItem {
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
}
.tag(AppState.Tab.conflicts)
.badge(syncCoordinator.pendingConflicts.count)
RulesView()
.tabItem {
Label("Regeln", systemImage: "slider.horizontal.3")
}
.tag(AppState.Tab.rules)
SourcesView()
.tabItem {
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(AppState.Tab.sources)
}
.tint(.blue)
}
}
#Preview {
ContentView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+379
View File
@@ -0,0 +1,379 @@
import SwiftUI
struct DashboardView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
@StateObject private var dataReader = DataReader.shared
@State private var dailySummary: DailySummary?
@State private var isLoading = false
@State private var selectedDate = Date()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Sync Status Card
syncStatusCard
// Date Picker
datePicker
// Health Metrics
if let summary = dailySummary {
healthMetricsGrid(summary: summary)
} else if isLoading {
loadingView
} else {
emptyStateView
}
// Pending Conflicts Alert
if !syncCoordinator.pendingConflicts.isEmpty {
pendingConflictsCard
}
// Recent Sync History
recentSyncHistory
}
.padding()
}
.navigationTitle("HealthBridge")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await performSync() }
} label: {
if syncCoordinator.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(syncCoordinator.isSyncing)
}
}
.refreshable {
await loadData()
}
.task {
await loadData()
}
.onChange(of: selectedDate) {
Task { await loadData() }
}
}
}
// MARK: - Sync Status Card
private var syncStatusCard: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Heute")
.font(.headline)
Text(syncCoordinator.todayStats.formattedLastSync)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if syncCoordinator.isSyncing {
VStack(alignment: .trailing) {
ProgressView(value: syncCoordinator.syncProgress)
.frame(width: 60)
Text("Synchronisiere...")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
Divider()
HStack(spacing: 20) {
StatItem(
value: "\(syncCoordinator.todayStats.syncCount)",
label: "Syncs",
icon: "arrow.triangle.2.circlepath"
)
StatItem(
value: "\(syncCoordinator.todayStats.totalConflicts)",
label: "Konflikte",
icon: "exclamationmark.triangle"
)
StatItem(
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
label: "Gelöst",
icon: "checkmark.circle"
)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Date Picker
private var datePicker: some View {
DatePicker(
"Datum",
selection: $selectedDate,
in: ...Date(),
displayedComponents: .date
)
.datePickerStyle(.compact)
.padding(.horizontal)
}
// MARK: - Health Metrics Grid
private func healthMetricsGrid(summary: DailySummary) -> some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
ForEach(HealthDataType.allCases) { dataType in
if let value = summary.values[dataType], value > 0 {
HealthMetricCard(
dataType: dataType,
value: summary.formattedValue(for: dataType),
conflictCount: summary.conflictCounts[dataType] ?? 0
)
}
}
}
}
// MARK: - Pending Conflicts Card
private var pendingConflictsCard: some View {
Button {
appState.selectedTab = .conflicts
} label: {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
.font(.headline)
Text("Tippen zum Anzeigen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - Recent Sync History
private var recentSyncHistory: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Letzte Synchronisierungen")
.font(.headline)
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
SyncHistoryRow(result: result)
}
if syncCoordinator.syncHistory.isEmpty {
Text("Keine Synchronisierungen")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Loading & Empty States
private var loadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Lade Daten...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Keine Daten verfügbar")
.font(.headline)
Text("Synchronisieren Sie, um Daten zu laden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Actions
private func loadData() async {
isLoading = true
defer { isLoading = false }
do {
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
} catch {
print("Failed to load data: \(error)")
}
}
private func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData()
} catch {
print("Sync failed: \(error)")
}
}
}
// MARK: - Supporting Views
struct StatItem: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct HealthMetricCard: View {
let dataType: HealthDataType
let value: String
let conflictCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Spacer()
if conflictCount > 0 {
Text("\(conflictCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.clipShape(Capsule())
}
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
struct SyncHistoryRow: View {
let result: SyncResult
var body: some View {
HStack {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(formattedDate)
.font(.subheadline)
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(result.formattedDuration)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private var statusIcon: String {
switch result.status {
case .success: return "checkmark.circle.fill"
case .partialSuccess: return "exclamationmark.circle.fill"
case .failed: return "xmark.circle.fill"
case .inProgress: return "arrow.clockwise"
}
}
private var statusColor: Color {
switch result.status {
case .success: return .green
case .partialSuccess: return .orange
case .failed: return .red
case .inProgress: return .blue
}
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: result.startedAt)
}
}
#Preview {
DashboardView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+252
View File
@@ -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()
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
struct SettingsView: View {
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
@StateObject private var syncCoordinator = SyncCoordinator.shared
@StateObject private var healthKitManager = HealthKitManager.shared
@State private var showingClearDataConfirmation = false
@State private var showingExportSheet = false
var body: some View {
NavigationStack {
Form {
// Sync Settings
Section("Synchronisierung") {
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
if backgroundSyncEnabled {
Picker("Intervall", selection: $syncIntervalMinutes) {
Text("15 Minuten").tag(15)
Text("30 Minuten").tag(30)
Text("1 Stunde").tag(60)
Text("2 Stunden").tag(120)
}
}
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
}
// Notification Settings
Section("Benachrichtigungen") {
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
if notificationsEnabled {
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
}
}
// Health Status
Section("HealthKit Status") {
HStack {
Text("Autorisierung")
Spacer()
if healthKitManager.isAuthorized {
Label("Erteilt", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Label("Ausstehend", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
if !healthKitManager.isAuthorized {
Button("HealthKit-Zugriff anfordern") {
Task {
try? await healthKitManager.requestAuthorization()
}
}
}
}
// Statistics
Section("Statistiken") {
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
}
// Data Management
Section("Daten") {
Button("Sync-Verlauf exportieren") {
showingExportSheet = true
}
Button("Sync-Verlauf löschen", role: .destructive) {
showingClearDataConfirmation = true
}
}
// About
Section("Info") {
HStack {
Text("Version")
Spacer()
Text("1.0.0")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://apple.com/health")!) {
HStack {
Text("Apple Health")
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Einstellungen")
.confirmationDialog(
"Sync-Verlauf löschen?",
isPresented: $showingClearDataConfirmation,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
syncCoordinator.clearHistory()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
}
.sheet(isPresented: $showingExportSheet) {
ExportView()
}
}
}
}
// MARK: - Stat Row
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Export View
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var syncCoordinator = SyncCoordinator.shared
@State private var exportFormat: ExportFormat = .json
@State private var isExporting = false
enum ExportFormat: String, CaseIterable {
case json = "JSON"
case csv = "CSV"
}
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Daten exportieren")
.font(.title2)
.fontWeight(.semibold)
Picker("Format", selection: $exportFormat) {
ForEach(ExportFormat.allCases, id: \.self) { format in
Text(format.rawValue).tag(format)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button {
exportData()
} label: {
Label("Exportieren", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding()
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
}
}
}
private func exportData() {
isExporting = true
// In a real app, this would create and share a file
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isExporting = false
dismiss()
}
}
}
#Preview {
SettingsView()
}
+407
View File
@@ -0,0 +1,407 @@
import SwiftUI
struct SourcesView: View {
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedSource: HealthSource?
@State private var isRefreshing = false
var body: some View {
NavigationStack {
Group {
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
emptyState
} else {
sourcesList
}
}
.navigationTitle("Quellen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await refreshSources() }
} label: {
if isRefreshing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.sheet(item: $selectedSource) { source in
SourceDetailView(source: source)
}
.task {
if sourceManager.sources.isEmpty {
await refreshSources()
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Keine Quellen gefunden")
.font(.title2)
.fontWeight(.semibold)
Text("Verbinden Sie Geräte mit Apple Health")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
Task { await refreshSources() }
} label: {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
.padding()
}
// MARK: - Sources List
private var sourcesList: some View {
List {
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
Section(category.displayName) {
ForEach(groupedSources[category] ?? []) { source in
SourceRow(source: source)
.contentShape(Rectangle())
.onTapGesture {
selectedSource = source
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await refreshSources()
}
}
private var groupedSources: [SourceCategory: [HealthSource]] {
Dictionary(grouping: sourceManager.sources, by: { $0.category })
}
private func refreshSources() async {
isRefreshing = true
defer { isRefreshing = false }
await sourceManager.discoverSources()
}
}
// MARK: - Source Row
struct SourceRow: View {
let source: HealthSource
@StateObject private var sourceManager = SourceManager.shared
var body: some View {
HStack {
Image(systemName: source.category.icon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 40)
VStack(alignment: .leading, spacing: 4) {
Text(source.displayName)
.font(.headline)
Text("\(source.supportedDataTypes.count) Datentypen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let status = sourceManager.sourceHealthStatus[source.id] {
Image(systemName: status.syncStatus.icon)
.foregroundStyle(statusColor(for: status.syncStatus))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
switch status {
case .recentlySynced: return .green
case .syncedToday: return .blue
case .stale: return .orange
case .veryStale, .neverSynced: return .red
}
}
}
// MARK: - Source Detail View
struct SourceDetailView: View {
let source: HealthSource
@Environment(\.dismiss) private var dismiss
@StateObject private var sourceManager = SourceManager.shared
@State private var healthReport: SourceHealthReport?
@State private var isLoading = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Capabilities
capabilitiesSection
// Data Types
dataTypesSection
// Health Report
if let report = healthReport {
healthReportSection(report)
}
// Priority Settings
prioritySection
}
.padding()
}
.navigationTitle(source.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") {
dismiss()
}
}
}
.task {
await loadHealthReport()
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 12) {
Image(systemName: source.category.icon)
.font(.system(size: 48))
.foregroundStyle(.blue)
Text(source.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(source.category.displayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(source.bundleIdentifier)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding()
}
// MARK: - Capabilities
private var capabilitiesSection: some View {
let capabilities = sourceManager.getSourceCapabilities(source)
return VStack(alignment: .leading, spacing: 12) {
Text("Fähigkeiten")
.font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Data Types
private var dataTypesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Unterstützte Datentypen")
.font(.headline)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 24)
Text(dataType.displayName)
Spacer()
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Health Report
private func healthReportSection(_ report: SourceHealthReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Zustand")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Datensätze (24h)")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(report.totalRecordCount)")
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Qualität")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: report.overallQuality.icon)
.font(.title3)
.foregroundStyle(qualityColor(report.overallQuality))
}
}
if let lastActivity = report.lastOverallActivity {
HStack {
Text("Letzte Aktivität")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formattedDate(lastActivity))
.font(.subheadline)
}
}
if report.hasSignificantGaps {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Datenlücken erkannt")
.font(.subheadline)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Priority Section
private var prioritySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Priorität")
.font(.headline)
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Text(dataType.displayName)
Spacer()
Stepper(
"\(sourceManager.getPriority(for: source, dataType: dataType))",
value: Binding(
get: { sourceManager.getPriority(for: source, dataType: dataType) },
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
),
in: 0...100,
step: 10
)
.frame(width: 150)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Helpers
private func loadHealthReport() async {
isLoading = true
defer { isLoading = false }
healthReport = await sourceManager.getSourceHealth(source)
}
private func qualityColor(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
private func formattedDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Capability Badge
struct CapabilityBadge: View {
let name: String
let available: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(available ? .green : .secondary)
Text(name)
.font(.caption)
.foregroundStyle(available ? .primary : .secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
.clipShape(Capsule())
}
}
#Preview {
SourcesView()
}