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
408 lines
13 KiB
Swift
408 lines
13 KiB
Swift
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()
|
|
}
|