Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec8d734ee | |||
| 60dab1e9df | |||
| 191381ece4 | |||
| fabdfb121a | |||
| 4454adca59 | |||
| 9ae417cb03 | |||
| 367aa4c67b | |||
| cac3768885 | |||
| b953908f58 | |||
| 0ffb1c771e | |||
| 8858a08a32 | |||
| faa36d0e5e |
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HealthBridgeApp: App {
|
||||||
|
@StateObject private var appState = AppState()
|
||||||
|
@StateObject private var healthKitManager = HealthKitManager.shared
|
||||||
|
@StateObject private var syncCoordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerBackgroundTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(healthKitManager)
|
||||||
|
.environmentObject(syncCoordinator)
|
||||||
|
.onAppear {
|
||||||
|
Task {
|
||||||
|
await requestHealthKitAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerBackgroundTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: "com.healthbridge.sync",
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let bgTask = task as? BGAppRefreshTask else { return }
|
||||||
|
handleBackgroundSync(task: bgTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundSync(task: BGAppRefreshTask) {
|
||||||
|
scheduleNextBackgroundSync()
|
||||||
|
|
||||||
|
let syncTask = Task {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.expirationHandler = {
|
||||||
|
syncTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleNextBackgroundSync() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: "com.healthbridge.sync")
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule background sync: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestHealthKitAuthorization() async {
|
||||||
|
do {
|
||||||
|
try await healthKitManager.requestAuthorization()
|
||||||
|
} catch {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App State
|
||||||
|
@MainActor
|
||||||
|
class AppState: ObservableObject {
|
||||||
|
@Published var selectedTab: Tab = .dashboard
|
||||||
|
@Published var showingConflictDetail: Conflict?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case dashboard
|
||||||
|
case conflicts
|
||||||
|
case rules
|
||||||
|
case sources
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Conflict
|
||||||
|
struct Conflict: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var status: ConflictStatus
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var appliedStrategy: MergeStrategy?
|
||||||
|
let detectedAt: Date
|
||||||
|
var resolvedAt: Date?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
status: ConflictStatus = .pending,
|
||||||
|
resolution: ConflictResolution? = nil,
|
||||||
|
appliedStrategy: MergeStrategy? = nil,
|
||||||
|
detectedAt: Date = Date(),
|
||||||
|
resolvedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.status = status
|
||||||
|
self.resolution = resolution
|
||||||
|
self.appliedStrategy = appliedStrategy
|
||||||
|
self.detectedAt = detectedAt
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
return (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentageDifference: Double {
|
||||||
|
guard readings.count >= 2 else { return 0 }
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
guard let min = values.min(), min > 0 else { return 0 }
|
||||||
|
guard let max = values.max() else { return 0 }
|
||||||
|
return ((max - min) / min) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
var severity: ConflictSeverity {
|
||||||
|
let pctDiff = percentageDifference
|
||||||
|
if pctDiff < 5 { return .minor }
|
||||||
|
if pctDiff < 20 { return .moderate }
|
||||||
|
if pctDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
var highestValueReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var lowestValueReading: SourceReading? {
|
||||||
|
readings.min(by: { $0.value < $1.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
var primarySourceReading: SourceReading? {
|
||||||
|
readings.max(by: { $0.sourceCategory.priority < $1.sourceCategory.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Status
|
||||||
|
enum ConflictStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case resolved = "resolved"
|
||||||
|
case manualReview = "manual_review"
|
||||||
|
case ignored = "ignored"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "Offen"
|
||||||
|
case .resolved: return "Gelöst"
|
||||||
|
case .manualReview: return "Manuelle Prüfung"
|
||||||
|
case .ignored: return "Ignoriert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .pending: return "clock.fill"
|
||||||
|
case .resolved: return "checkmark.circle.fill"
|
||||||
|
case .manualReview: return "hand.raised.fill"
|
||||||
|
case .ignored: return "eye.slash.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Severity
|
||||||
|
enum ConflictSeverity: String, Codable {
|
||||||
|
case minor = "minor"
|
||||||
|
case moderate = "moderate"
|
||||||
|
case significant = "significant"
|
||||||
|
case major = "major"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "Gering"
|
||||||
|
case .moderate: return "Moderat"
|
||||||
|
case .significant: return "Erheblich"
|
||||||
|
case .major: return "Gross"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .minor: return "green"
|
||||||
|
case .moderate: return "yellow"
|
||||||
|
case .significant: return "orange"
|
||||||
|
case .major: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Resolution
|
||||||
|
struct ConflictResolution: Codable {
|
||||||
|
let resolvedValue: Double
|
||||||
|
let secondaryResolvedValue: Double? // For blood pressure
|
||||||
|
let winningSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let isManual: Bool
|
||||||
|
let resolvedAt: Date
|
||||||
|
let notes: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
resolvedValue: Double,
|
||||||
|
secondaryResolvedValue: Double? = nil,
|
||||||
|
winningSourceId: String,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
isManual: Bool = false,
|
||||||
|
resolvedAt: Date = Date(),
|
||||||
|
notes: String? = nil
|
||||||
|
) {
|
||||||
|
self.resolvedValue = resolvedValue
|
||||||
|
self.secondaryResolvedValue = secondaryResolvedValue
|
||||||
|
self.winningSourceId = winningSourceId
|
||||||
|
self.strategy = strategy
|
||||||
|
self.isManual = isManual
|
||||||
|
self.resolvedAt = resolvedAt
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Strategy
|
||||||
|
enum MergeStrategy: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case exclusive = "exclusive"
|
||||||
|
case priority = "priority"
|
||||||
|
case higherWins = "higher_wins"
|
||||||
|
case lowerWins = "lower_wins"
|
||||||
|
case average = "average"
|
||||||
|
case coverage = "coverage"
|
||||||
|
case coverageThenHigher = "coverage_then_higher"
|
||||||
|
case manual = "manual"
|
||||||
|
case mostRecent = "most_recent"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "Exklusiv"
|
||||||
|
case .priority: return "Priorität"
|
||||||
|
case .higherWins: return "Höherer Wert"
|
||||||
|
case .lowerWins: return "Niedrigerer Wert"
|
||||||
|
case .average: return "Durchschnitt"
|
||||||
|
case .coverage: return "Abdeckung"
|
||||||
|
case .coverageThenHigher: return "Abdeckung + Höher"
|
||||||
|
case .manual: return "Manuell"
|
||||||
|
case .mostRecent: return "Neuester"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive:
|
||||||
|
return "Nur eine Quelle kann diesen Datentyp liefern"
|
||||||
|
case .priority:
|
||||||
|
return "Höchste Priorität gewinnt basierend auf Benutzereinstellungen"
|
||||||
|
case .higherWins:
|
||||||
|
return "Der grössere Wert wird verwendet (z.B. mehr Schritte = war aktiv)"
|
||||||
|
case .lowerWins:
|
||||||
|
return "Der kleinere Wert wird verwendet"
|
||||||
|
case .average:
|
||||||
|
return "Durchschnitt aller Quellen"
|
||||||
|
case .coverage:
|
||||||
|
return "Quelle mit Daten für dieses Zeitfenster"
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return "Erst Abdeckung prüfen, dann höherer Wert bei Konflikt"
|
||||||
|
case .manual:
|
||||||
|
return "Benutzer entscheidet bei jedem Konflikt"
|
||||||
|
case .mostRecent:
|
||||||
|
return "Zuletzt erfasster Wert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .exclusive: return "1.circle.fill"
|
||||||
|
case .priority: return "list.number"
|
||||||
|
case .higherWins: return "arrow.up.circle.fill"
|
||||||
|
case .lowerWins: return "arrow.down.circle.fill"
|
||||||
|
case .average: return "divide.circle.fill"
|
||||||
|
case .coverage: return "square.fill.on.square.fill"
|
||||||
|
case .coverageThenHigher: return "square.stack.3d.up.fill"
|
||||||
|
case .manual: return "hand.raised.fill"
|
||||||
|
case .mostRecent: return "clock.arrow.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Rule
|
||||||
|
struct MergeRule: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var primarySourceId: String?
|
||||||
|
var fallbackSourceId: String?
|
||||||
|
var sourcePriorities: [String: Int]
|
||||||
|
var autoApply: Bool
|
||||||
|
var thresholdForManualReview: Double? // Percentage difference threshold
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
strategy: MergeStrategy,
|
||||||
|
primarySourceId: String? = nil,
|
||||||
|
fallbackSourceId: String? = nil,
|
||||||
|
sourcePriorities: [String: Int] = [:],
|
||||||
|
autoApply: Bool = true,
|
||||||
|
thresholdForManualReview: Double? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.strategy = strategy
|
||||||
|
self.primarySourceId = primarySourceId
|
||||||
|
self.fallbackSourceId = fallbackSourceId
|
||||||
|
self.sourcePriorities = sourcePriorities
|
||||||
|
self.autoApply = autoApply
|
||||||
|
self.thresholdForManualReview = thresholdForManualReview
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
switch dataType {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .exclusive)
|
||||||
|
case .steps, .distance, .activeEnergy:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .coverageThenHigher)
|
||||||
|
case .sleep:
|
||||||
|
return MergeRule(dataType: dataType, strategy: .priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Record
|
||||||
|
struct SyncRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
var readings: [SourceReading]
|
||||||
|
var mergedValue: Double?
|
||||||
|
var secondaryMergedValue: Double? // For blood pressure
|
||||||
|
var strategy: MergeStrategy
|
||||||
|
var status: SyncStatus
|
||||||
|
var hasConflict: Bool
|
||||||
|
var conflictId: UUID?
|
||||||
|
let createdAt: Date
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case pending = "pending"
|
||||||
|
case processing = "processing"
|
||||||
|
case completed = "completed"
|
||||||
|
case failed = "failed"
|
||||||
|
case requiresManualReview = "requires_manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dataType: HealthDataType,
|
||||||
|
timeWindow: TimeWindow,
|
||||||
|
readings: [SourceReading],
|
||||||
|
mergedValue: Double? = nil,
|
||||||
|
secondaryMergedValue: Double? = nil,
|
||||||
|
strategy: MergeStrategy = .priority,
|
||||||
|
status: SyncStatus = .pending,
|
||||||
|
hasConflict: Bool = false,
|
||||||
|
conflictId: UUID? = nil,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
processedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.dataType = dataType
|
||||||
|
self.timeWindow = timeWindow
|
||||||
|
self.readings = readings
|
||||||
|
self.mergedValue = mergedValue
|
||||||
|
self.secondaryMergedValue = secondaryMergedValue
|
||||||
|
self.strategy = strategy
|
||||||
|
self.status = status
|
||||||
|
self.hasConflict = hasConflict
|
||||||
|
self.conflictId = conflictId
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.processedAt = processedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Data Type
|
||||||
|
enum HealthDataType: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case steps = "steps"
|
||||||
|
case heartRate = "heart_rate"
|
||||||
|
case bloodPressureSystolic = "blood_pressure_systolic"
|
||||||
|
case bloodPressureDiastolic = "blood_pressure_diastolic"
|
||||||
|
case bloodOxygen = "blood_oxygen"
|
||||||
|
case sleep = "sleep"
|
||||||
|
case distance = "distance"
|
||||||
|
case floorsClimbed = "floors_climbed"
|
||||||
|
case activeEnergy = "active_energy"
|
||||||
|
case restingHeartRate = "resting_heart_rate"
|
||||||
|
case heartRateVariability = "hrv"
|
||||||
|
case respiratoryRate = "respiratory_rate"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate: return "Herzfrequenz"
|
||||||
|
case .bloodPressureSystolic: return "Blutdruck (Systolisch)"
|
||||||
|
case .bloodPressureDiastolic: return "Blutdruck (Diastolisch)"
|
||||||
|
case .bloodOxygen: return "Blutsauerstoff (SpO2)"
|
||||||
|
case .sleep: return "Schlaf"
|
||||||
|
case .distance: return "Distanz"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "Aktive Energie"
|
||||||
|
case .restingHeartRate: return "Ruhepuls"
|
||||||
|
case .heartRateVariability: return "HRV"
|
||||||
|
case .respiratoryRate: return "Atemfrequenz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "figure.walk"
|
||||||
|
case .heartRate, .restingHeartRate: return "heart.fill"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "drop.fill"
|
||||||
|
case .bloodOxygen: return "lungs.fill"
|
||||||
|
case .sleep: return "bed.double.fill"
|
||||||
|
case .distance: return "map.fill"
|
||||||
|
case .floorsClimbed: return "stairs"
|
||||||
|
case .activeEnergy: return "flame.fill"
|
||||||
|
case .heartRateVariability: return "waveform.path.ecg"
|
||||||
|
case .respiratoryRate: return "wind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit: String {
|
||||||
|
switch self {
|
||||||
|
case .steps: return "Schritte"
|
||||||
|
case .heartRate, .restingHeartRate: return "bpm"
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic: return "mmHg"
|
||||||
|
case .bloodOxygen: return "%"
|
||||||
|
case .sleep: return "h"
|
||||||
|
case .distance: return "km"
|
||||||
|
case .floorsClimbed: return "Stockwerke"
|
||||||
|
case .activeEnergy: return "kcal"
|
||||||
|
case .heartRateVariability: return "ms"
|
||||||
|
case .respiratoryRate: return "/min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkQuantityType: HKQuantityType? {
|
||||||
|
switch self {
|
||||||
|
case .steps:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||||
|
case .heartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRate)
|
||||||
|
case .bloodPressureSystolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic)
|
||||||
|
case .bloodPressureDiastolic:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .oxygenSaturation)
|
||||||
|
case .distance:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .flightsClimbed)
|
||||||
|
case .activeEnergy:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)
|
||||||
|
case .restingHeartRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .restingHeartRate)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return HKQuantityType.quantityType(forIdentifier: .respiratoryRate)
|
||||||
|
case .sleep:
|
||||||
|
return nil // Sleep uses category type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkCategoryType: HKCategoryType? {
|
||||||
|
switch self {
|
||||||
|
case .sleep:
|
||||||
|
return HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkUnit: HKUnit {
|
||||||
|
switch self {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return .count()
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return HKUnit.count().unitDivided(by: .minute())
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return .millimeterOfMercury()
|
||||||
|
case .bloodOxygen:
|
||||||
|
return .percent()
|
||||||
|
case .sleep:
|
||||||
|
return .hour()
|
||||||
|
case .distance:
|
||||||
|
return .meterUnit(with: .kilo)
|
||||||
|
case .activeEnergy:
|
||||||
|
return .kilocalorie()
|
||||||
|
case .heartRateVariability:
|
||||||
|
return .secondUnit(with: .milli)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default primary source for this data type
|
||||||
|
var defaultPrimarySource: SourceCategory {
|
||||||
|
switch self {
|
||||||
|
case .floorsClimbed:
|
||||||
|
return .iPhone
|
||||||
|
case .steps, .heartRate, .bloodPressureSystolic, .bloodPressureDiastolic,
|
||||||
|
.bloodOxygen, .sleep, .distance, .activeEnergy, .restingHeartRate,
|
||||||
|
.heartRateVariability, .respiratoryRate:
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this data type typically has only one source
|
||||||
|
var isExclusive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodOxygen,
|
||||||
|
.heartRate, .restingHeartRate, .heartRateVariability, .respiratoryRate, .sleep:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Category
|
||||||
|
enum SourceCategory: String, Codable, CaseIterable {
|
||||||
|
case iPhone = "iphone"
|
||||||
|
case watch = "watch"
|
||||||
|
case thirdPartyWatch = "third_party_watch"
|
||||||
|
case thirdPartyApp = "third_party_app"
|
||||||
|
case healthBridge = "health_bridge"
|
||||||
|
case unknown = "unknown"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iPhone"
|
||||||
|
case .watch: return "Apple Watch"
|
||||||
|
case .thirdPartyWatch: return "Drittanbieter-Watch"
|
||||||
|
case .thirdPartyApp: return "Drittanbieter-App"
|
||||||
|
case .healthBridge: return "HealthBridge"
|
||||||
|
case .unknown: return "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .iPhone: return "iphone"
|
||||||
|
case .watch: return "applewatch"
|
||||||
|
case .thirdPartyWatch: return "applewatch.side.right"
|
||||||
|
case .thirdPartyApp: return "app.badge"
|
||||||
|
case .healthBridge: return "arrow.triangle.2.circlepath"
|
||||||
|
case .unknown: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var priority: Int {
|
||||||
|
switch self {
|
||||||
|
case .healthBridge: return 100
|
||||||
|
case .watch: return 80
|
||||||
|
case .thirdPartyWatch: return 70
|
||||||
|
case .iPhone: return 50
|
||||||
|
case .thirdPartyApp: return 30
|
||||||
|
case .unknown: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Quality
|
||||||
|
enum DataQuality: String, Codable {
|
||||||
|
case complete = "complete"
|
||||||
|
case partial = "partial"
|
||||||
|
case missing = "missing"
|
||||||
|
case invalid = "invalid"
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "checkmark.circle.fill"
|
||||||
|
case .partial: return "circle.lefthalf.filled"
|
||||||
|
case .missing: return "circle.dashed"
|
||||||
|
case .invalid: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .complete: return "green"
|
||||||
|
case .partial: return "yellow"
|
||||||
|
case .missing: return "gray"
|
||||||
|
case .invalid: return "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Window
|
||||||
|
struct TimeWindow: Codable, Hashable, Identifiable {
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
|
||||||
|
var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" }
|
||||||
|
|
||||||
|
var interval: DateInterval {
|
||||||
|
DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedRange: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return "\(formatter.string(from: start)) - \(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: start)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func windows(for date: Date, intervalMinutes: Int = 15) -> [TimeWindow] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = startOfDay
|
||||||
|
|
||||||
|
while current < endOfDay {
|
||||||
|
let windowEnd = calendar.date(byAdding: .minute, value: intervalMinutes, to: current)!
|
||||||
|
windows.append(TimeWindow(start: current, end: min(windowEnd, endOfDay)))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hourlyWindows(for date: Date) -> [TimeWindow] {
|
||||||
|
windows(for: date, intervalMinutes: 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
// MARK: - Health Source
|
||||||
|
struct HealthSource: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let bundleIdentifier: String
|
||||||
|
let name: String
|
||||||
|
let category: SourceCategory
|
||||||
|
var supportedDataTypes: Set<HealthDataType>
|
||||||
|
var lastActivityDate: Date?
|
||||||
|
var userPriorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
bundleIdentifier: String,
|
||||||
|
name: String,
|
||||||
|
category: SourceCategory,
|
||||||
|
supportedDataTypes: Set<HealthDataType> = [],
|
||||||
|
lastActivityDate: Date? = nil,
|
||||||
|
userPriorities: [HealthDataType: Int] = [:],
|
||||||
|
isEnabled: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.bundleIdentifier = bundleIdentifier
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.supportedDataTypes = supportedDataTypes
|
||||||
|
self.lastActivityDate = lastActivityDate
|
||||||
|
self.userPriorities = userPriorities
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
if name.isEmpty {
|
||||||
|
return bundleIdentifier.components(separatedBy: ".").last ?? bundleIdentifier
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
var isHealthBridge: Bool {
|
||||||
|
bundleIdentifier == HealthBridgeConstants.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func priority(for dataType: HealthDataType) -> Int {
|
||||||
|
userPriorities[dataType] ?? category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(hkSource: HKSource) -> HealthSource {
|
||||||
|
let category = classifySource(bundleId: hkSource.bundleIdentifier)
|
||||||
|
return HealthSource(
|
||||||
|
id: hkSource.bundleIdentifier,
|
||||||
|
bundleIdentifier: hkSource.bundleIdentifier,
|
||||||
|
name: hkSource.name,
|
||||||
|
category: category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifySource(bundleId: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleId.lowercased()
|
||||||
|
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if lowercased.contains("apple.health") {
|
||||||
|
return .iPhone
|
||||||
|
} else if lowercased.contains("watch") || lowercased.contains("applewatch") {
|
||||||
|
return .watch
|
||||||
|
} else if lowercased.contains("huawei") || lowercased.contains("samsung") ||
|
||||||
|
lowercased.contains("fitbit") || lowercased.contains("garmin") ||
|
||||||
|
lowercased.contains("polar") || lowercased.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Reading
|
||||||
|
struct SourceReading: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let sourceId: String
|
||||||
|
let sourceName: String
|
||||||
|
let sourceCategory: SourceCategory
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double? // For blood pressure (diastolic)
|
||||||
|
let timestamp: Date
|
||||||
|
let originalRecordId: String?
|
||||||
|
let quality: DataQuality
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
sourceId: String,
|
||||||
|
sourceName: String,
|
||||||
|
sourceCategory: SourceCategory,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
timestamp: Date,
|
||||||
|
originalRecordId: String? = nil,
|
||||||
|
quality: DataQuality = .complete
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.sourceId = sourceId
|
||||||
|
self.sourceName = sourceName
|
||||||
|
self.sourceCategory = sourceCategory
|
||||||
|
self.value = value
|
||||||
|
self.secondaryValue = secondaryValue
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.originalRecordId = originalRecordId
|
||||||
|
self.quality = quality
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Status
|
||||||
|
struct SourceHealthStatus: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let source: HealthSource
|
||||||
|
let lastSync: Date?
|
||||||
|
let recordCount: Int
|
||||||
|
let dataGaps: [TimeWindow]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
guard let lastSync = lastSync else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .recentlySynced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .syncedToday
|
||||||
|
} else if hoursSinceSync < 72 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case recentlySynced
|
||||||
|
case syncedToday
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "checkmark.circle.fill"
|
||||||
|
case .syncedToday: return "checkmark.circle"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlySynced: return "Kürzlich synchronisiert"
|
||||||
|
case .syncedToday: return "Heute synchronisiert"
|
||||||
|
case .stale: return "Sync überfällig"
|
||||||
|
case .veryStale: return "Lange nicht synchronisiert"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
enum HealthBridgeConstants {
|
||||||
|
static let bundleIdentifier = "com.healthbridge.merged"
|
||||||
|
static let displayName = "HealthBridge"
|
||||||
|
static let defaultSyncInterval: TimeInterval = 15 * 60 // 15 minutes
|
||||||
|
static let conflictThreshold: TimeInterval = 60 // 1 minute overlap tolerance
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# HealthBridge
|
||||||
|
|
||||||
|
Intelligente Health-Daten-Synchronisation für iOS – Eine "Single Source of Truth" für Gesundheitsdaten.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
HealthBridge liest alle Quellen aus Apple Health, erkennt Konflikte zwischen verschiedenen Geräten und Apps, merged intelligent basierend auf konfigurierbaren Regeln und schreibt bereinigte Daten zurück.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Source Discovery
|
||||||
|
- Automatische Erkennung aller verbundenen Datenquellen
|
||||||
|
- Klassifizierung nach Gerätetyp (iPhone, Apple Watch, Drittanbieter-Watch, Apps)
|
||||||
|
- Übersicht über Fähigkeiten und unterstützte Datentypen pro Quelle
|
||||||
|
|
||||||
|
### Konflikt-Erkennung
|
||||||
|
- Automatische Erkennung von Datenkonflikten zwischen Quellen
|
||||||
|
- Zeitfenster-basierte Analyse (15-Minuten-Intervalle)
|
||||||
|
- Schweregrad-Klassifizierung (minor, moderate, significant, major)
|
||||||
|
|
||||||
|
### Merge-Strategien
|
||||||
|
- **Exclusive**: Nur eine Quelle möglich (z.B. Blutdruck, SpO2)
|
||||||
|
- **Priority**: Fixe Rangfolge basierend auf Benutzereinstellungen
|
||||||
|
- **Higher Wins**: Grösserer Wert gewinnt (ideal für Schritte)
|
||||||
|
- **Coverage**: Quelle mit Daten für Zeitfenster gewinnt
|
||||||
|
- **Coverage Then Higher**: Erst Abdeckung, dann höherer Wert
|
||||||
|
- **Average**: Durchschnitt aller Quellen
|
||||||
|
- **Manual**: Benutzer entscheidet bei jedem Konflikt
|
||||||
|
|
||||||
|
### UI-Komponenten
|
||||||
|
- **Dashboard**: Tagesübersicht aller Gesundheitsdaten mit Sync-Status
|
||||||
|
- **Konflikte**: Liste offener Konflikte mit One-Tap-Auflösung
|
||||||
|
- **Regeln**: Konfiguration der Merge-Strategien pro Datentyp
|
||||||
|
- **Quellen**: Übersicht aller erkannten Datenquellen
|
||||||
|
|
||||||
|
### Background Sync
|
||||||
|
- Automatische Synchronisierung im Hintergrund
|
||||||
|
- Konfigurierbares Intervall (15 Min bis 2 Stunden)
|
||||||
|
- Push-Benachrichtigungen bei neuen Konflikten
|
||||||
|
|
||||||
|
## Unterstützte Datentypen
|
||||||
|
|
||||||
|
| Datentyp | Primärquelle | Strategie |
|
||||||
|
|----------|--------------|-----------|
|
||||||
|
| Schritte | Watch | Coverage + Higher |
|
||||||
|
| Herzfrequenz | Watch | Exclusive |
|
||||||
|
| Blutdruck | Watch D2 | Exclusive |
|
||||||
|
| SpO2 | Watch | Exclusive |
|
||||||
|
| Schlaf | Watch | Priority |
|
||||||
|
| Distanz | Watch/iPhone | Coverage + Higher |
|
||||||
|
| Stockwerke | iPhone | Exclusive |
|
||||||
|
| Aktive Energie | Watch | Coverage + Higher |
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Apple Health │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HealthBridge App │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ DataReader │→ │ MergeEngine │→ │ DataWriter │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │SourceManager│ │ RuleEngine │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SyncCoordinator │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
HealthBridge/
|
||||||
|
├── App/
|
||||||
|
│ └── HealthBridgeApp.swift # App-Entry, Background Tasks
|
||||||
|
├── Models/
|
||||||
|
│ ├── HealthDataTypes.swift # Datentypen, TimeWindow
|
||||||
|
│ ├── Source.swift # HealthSource, SourceReading
|
||||||
|
│ └── Conflict.swift # Conflict, MergeStrategy, MergeRule
|
||||||
|
├── Services/
|
||||||
|
│ ├── HealthKitManager.swift # HealthKit-Integration
|
||||||
|
│ ├── SourceManager.swift # Source Discovery & Management
|
||||||
|
│ ├── DataReader.swift # Daten lesen, Konflikte erkennen
|
||||||
|
│ ├── RuleEngine.swift # Merge-Regeln verwalten & anwenden
|
||||||
|
│ ├── MergeEngine.swift # Konflikte analysieren & lösen
|
||||||
|
│ ├── DataWriter.swift # Daten zurückschreiben
|
||||||
|
│ └── SyncCoordinator.swift # Orchestrierung aller Services
|
||||||
|
├── Views/
|
||||||
|
│ ├── ContentView.swift # Tab-Navigation
|
||||||
|
│ ├── DashboardView.swift # Hauptübersicht
|
||||||
|
│ ├── ConflictsView.swift # Konflikt-Liste & Detail
|
||||||
|
│ ├── RulesView.swift # Regelwerk-Editor
|
||||||
|
│ ├── SourcesView.swift # Quellen-Übersicht
|
||||||
|
│ ├── SettingsView.swift # Einstellungen
|
||||||
|
│ └── Components/
|
||||||
|
│ └── HealthChart.swift # Diagramm-Komponenten
|
||||||
|
├── ViewModels/
|
||||||
|
│ └── DashboardViewModel.swift # Dashboard-Logik
|
||||||
|
├── Utils/
|
||||||
|
│ ├── Extensions.swift # Swift-Erweiterungen
|
||||||
|
│ └── NotificationManager.swift # Push-Benachrichtigungen
|
||||||
|
└── Resources/
|
||||||
|
├── Info.plist # App-Konfiguration
|
||||||
|
└── HealthBridge.entitlements # HealthKit-Berechtigungen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- iOS 16.0+
|
||||||
|
- Xcode 15.0+
|
||||||
|
- Apple Developer Account (für HealthKit-Entitlements)
|
||||||
|
- Physisches Gerät (HealthKit nicht im Simulator verfügbar)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Projekt in Xcode öffnen
|
||||||
|
2. Team für Code Signing auswählen
|
||||||
|
3. HealthKit-Capability aktivieren
|
||||||
|
4. Auf physischem Gerät ausführen
|
||||||
|
|
||||||
|
## Berechtigungen
|
||||||
|
|
||||||
|
Die App benötigt folgende Berechtigungen:
|
||||||
|
- **HealthKit Read**: Lesen aller Gesundheitsdaten
|
||||||
|
- **HealthKit Write**: Schreiben gemergter Daten
|
||||||
|
- **Background App Refresh**: Für automatische Synchronisierung
|
||||||
|
- **Notifications**: Für Konflikt-Benachrichtigungen
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array>
|
||||||
|
<string>health-records</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
|
<true/>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HealthBridge</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchScreenBackground</string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>LaunchIcon</string>
|
||||||
|
</dict>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
<string>healthkit</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- HealthKit -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>HealthBridge benötigt Zugriff auf Ihre Gesundheitsdaten, um diese zwischen verschiedenen Quellen zu synchronisieren und Konflikte zu lösen.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>HealthBridge schreibt bereinigte Gesundheitsdaten zurück in Apple Health, um eine konsistente Datenbasis zu gewährleisten.</string>
|
||||||
|
|
||||||
|
<!-- Background Modes -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.healthbridge.sync</string>
|
||||||
|
<string>com.healthbridge.cleanup</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Data Reader
|
||||||
|
@MainActor
|
||||||
|
class DataReader: ObservableObject {
|
||||||
|
static let shared = DataReader()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var isReading = false
|
||||||
|
@Published var lastReadDate: Date?
|
||||||
|
@Published var detectedConflicts: [Conflict] = []
|
||||||
|
@Published var readingProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Fetch Data by Type and Date Range
|
||||||
|
|
||||||
|
func fetchData(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
groupByWindow intervalMinutes: Int = 15
|
||||||
|
) async throws -> [TimeWindowData] {
|
||||||
|
isReading = true
|
||||||
|
defer { isReading = false }
|
||||||
|
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group samples by source
|
||||||
|
let groupedBySource = groupBySource(samples: samples, dataType: dataType)
|
||||||
|
|
||||||
|
// Create time windows
|
||||||
|
let windows = generateTimeWindows(from: startDate, to: endDate, intervalMinutes: intervalMinutes)
|
||||||
|
|
||||||
|
// Assign samples to windows
|
||||||
|
var windowDataList: [TimeWindowData] = []
|
||||||
|
|
||||||
|
for window in windows {
|
||||||
|
let windowData = createWindowData(
|
||||||
|
window: window,
|
||||||
|
dataType: dataType,
|
||||||
|
groupedBySource: groupedBySource
|
||||||
|
)
|
||||||
|
windowDataList.append(windowData)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastReadDate = Date()
|
||||||
|
return windowDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
private func groupBySource(samples: [HKSample], dataType: HealthDataType) -> [String: [HKSample]] {
|
||||||
|
var grouped: [String: [HKSample]] = [:]
|
||||||
|
|
||||||
|
for sample in samples {
|
||||||
|
let sourceId = sample.sourceRevision.source.bundleIdentifier
|
||||||
|
if grouped[sourceId] == nil {
|
||||||
|
grouped[sourceId] = []
|
||||||
|
}
|
||||||
|
grouped[sourceId]?.append(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateTimeWindows(from start: Date, to end: Date, intervalMinutes: Int) -> [TimeWindow] {
|
||||||
|
var windows: [TimeWindow] = []
|
||||||
|
var current = start
|
||||||
|
|
||||||
|
while current < end {
|
||||||
|
let windowEnd = min(
|
||||||
|
Calendar.current.date(byAdding: .minute, value: intervalMinutes, to: current)!,
|
||||||
|
end
|
||||||
|
)
|
||||||
|
windows.append(TimeWindow(start: current, end: windowEnd))
|
||||||
|
current = windowEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createWindowData(
|
||||||
|
window: TimeWindow,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
groupedBySource: [String: [HKSample]]
|
||||||
|
) -> TimeWindowData {
|
||||||
|
var readings: [SourceReading] = []
|
||||||
|
|
||||||
|
for (sourceId, samples) in groupedBySource {
|
||||||
|
let windowSamples = samples.filter { sample in
|
||||||
|
sample.startDate < window.end && sample.endDate > window.start
|
||||||
|
}
|
||||||
|
|
||||||
|
if !windowSamples.isEmpty {
|
||||||
|
let reading = createReading(
|
||||||
|
from: windowSamples,
|
||||||
|
sourceId: sourceId,
|
||||||
|
dataType: dataType,
|
||||||
|
window: window
|
||||||
|
)
|
||||||
|
readings.append(reading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasConflict = detectConflict(in: readings, dataType: dataType)
|
||||||
|
|
||||||
|
return TimeWindowData(
|
||||||
|
timeWindow: window,
|
||||||
|
dataType: dataType,
|
||||||
|
readings: readings,
|
||||||
|
hasConflict: hasConflict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createReading(
|
||||||
|
from samples: [HKSample],
|
||||||
|
sourceId: String,
|
||||||
|
dataType: HealthDataType,
|
||||||
|
window: TimeWindow
|
||||||
|
) -> SourceReading {
|
||||||
|
let value: Double
|
||||||
|
var secondaryValue: Double? = nil
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
// Sum up values for cumulative types
|
||||||
|
value = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability, .bloodOxygen:
|
||||||
|
// Average for rate-based types
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
// For blood pressure, we need to handle correlations
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
value = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
// Sum up sleep duration
|
||||||
|
value = samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = sourceManager.sources.first { $0.bundleIdentifier == sourceId }
|
||||||
|
let category = source?.category ?? sourceManager.classifySource(sourceId)
|
||||||
|
|
||||||
|
return SourceReading(
|
||||||
|
sourceId: sourceId,
|
||||||
|
sourceName: source?.name ?? sourceId,
|
||||||
|
sourceCategory: category,
|
||||||
|
value: value,
|
||||||
|
secondaryValue: secondaryValue,
|
||||||
|
timestamp: window.start,
|
||||||
|
originalRecordId: samples.first?.uuid.uuidString,
|
||||||
|
quality: samples.isEmpty ? .missing : .complete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detection
|
||||||
|
|
||||||
|
private func detectConflict(in readings: [SourceReading], dataType: HealthDataType) -> Bool {
|
||||||
|
// No conflict if less than 2 readings
|
||||||
|
guard readings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Filter out zero values (device wasn't tracking)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
guard nonZeroReadings.count >= 2 else { return false }
|
||||||
|
|
||||||
|
// Check if values differ significantly
|
||||||
|
let values = nonZeroReadings.map { $0.value }
|
||||||
|
guard let minVal = values.min(), let maxVal = values.max() else { return false }
|
||||||
|
|
||||||
|
// Threshold varies by data type
|
||||||
|
let threshold = conflictThreshold(for: dataType)
|
||||||
|
|
||||||
|
if minVal == 0 {
|
||||||
|
return maxVal > threshold.absoluteThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (maxVal - minVal) / minVal * 100
|
||||||
|
return percentDiff > threshold.percentageThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func conflictThreshold(for dataType: HealthDataType) -> ConflictThreshold {
|
||||||
|
switch dataType {
|
||||||
|
case .steps:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 100)
|
||||||
|
case .distance:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.1) // 100m
|
||||||
|
case .heartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 10)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return ConflictThreshold(percentageThreshold: 5, absoluteThreshold: 5)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return ConflictThreshold(percentageThreshold: 2, absoluteThreshold: 2)
|
||||||
|
case .floorsClimbed:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 2)
|
||||||
|
case .activeEnergy:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 50)
|
||||||
|
case .sleep:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 0.5) // 30 min
|
||||||
|
case .restingHeartRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 10, absoluteThreshold: 5)
|
||||||
|
case .heartRateVariability:
|
||||||
|
return ConflictThreshold(percentageThreshold: 20, absoluteThreshold: 10)
|
||||||
|
case .respiratoryRate:
|
||||||
|
return ConflictThreshold(percentageThreshold: 15, absoluteThreshold: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detect All Conflicts
|
||||||
|
|
||||||
|
func detectConflicts(
|
||||||
|
for date: Date,
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws -> [Conflict] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var allConflicts: [Conflict] = []
|
||||||
|
|
||||||
|
for (index, dataType) in dataTypes.enumerated() {
|
||||||
|
readingProgress = Double(index) / Double(dataTypes.count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflicts = windowData
|
||||||
|
.filter { $0.hasConflict }
|
||||||
|
.map { data in
|
||||||
|
Conflict(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: data.timeWindow,
|
||||||
|
readings: data.readings,
|
||||||
|
status: .pending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
allConflicts.append(contentsOf: conflicts)
|
||||||
|
} catch {
|
||||||
|
print("Failed to detect conflicts for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readingProgress = 1.0
|
||||||
|
detectedConflicts = allConflicts
|
||||||
|
return allConflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Gaps Detection
|
||||||
|
|
||||||
|
func detectGaps(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date,
|
||||||
|
expectedIntervalMinutes: Int = 15
|
||||||
|
) async throws -> [DataGap] {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startDate,
|
||||||
|
to: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: endDate),
|
||||||
|
expectedRecordCount: 0,
|
||||||
|
actualRecordCount: 0
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
var gaps: [DataGap] = []
|
||||||
|
let expectedInterval = TimeInterval(expectedIntervalMinutes * 60)
|
||||||
|
|
||||||
|
// Check gap at start
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(startDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: startDate, end: firstSample.startDate),
|
||||||
|
expectedRecordCount: Int(firstSample.startDate.timeIntervalSince(startDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let current = sortedSamples[i]
|
||||||
|
let next = sortedSamples[i + 1]
|
||||||
|
let gap = next.startDate.timeIntervalSince(current.endDate)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: current.endDate, end: next.startDate),
|
||||||
|
expectedRecordCount: Int(gap / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
endDate.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(DataGap(
|
||||||
|
dataType: dataType,
|
||||||
|
timeWindow: TimeWindow(start: lastSample.endDate, end: endDate),
|
||||||
|
expectedRecordCount: Int(endDate.timeIntervalSince(lastSample.endDate) / expectedInterval),
|
||||||
|
actualRecordCount: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aggregated Data
|
||||||
|
|
||||||
|
func fetchDailySummary(for date: Date) async throws -> DailySummary {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var summary = DailySummary(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let value = aggregateValue(samples: samples, dataType: dataType)
|
||||||
|
summary.values[dataType] = value
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
let windowData = try await fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
let conflictCount = windowData.filter { $0.hasConflict }.count
|
||||||
|
summary.conflictCounts[dataType] = conflictCount
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch \(dataType) for summary: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aggregateValue(samples: [HKSample], dataType: HealthDataType) -> Double {
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed, .activeEnergy, .distance:
|
||||||
|
return samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}.reduce(0, +)
|
||||||
|
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate, .heartRateVariability,
|
||||||
|
.bloodOxygen, .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
let values = samples.compactMap { sample -> Double? in
|
||||||
|
guard let quantitySample = sample as? HKQuantitySample else { return nil }
|
||||||
|
return quantitySample.quantity.doubleValue(for: dataType.hkUnit)
|
||||||
|
}
|
||||||
|
return values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
case .sleep:
|
||||||
|
return samples.reduce(0) { acc, sample in
|
||||||
|
acc + sample.endDate.timeIntervalSince(sample.startDate) / 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct TimeWindowData: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let readings: [SourceReading]
|
||||||
|
let hasConflict: Bool
|
||||||
|
|
||||||
|
var primaryReading: SourceReading? {
|
||||||
|
readings.max { $0.sourceCategory.priority < $1.sourceCategory.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictSeverity: ConflictSeverity? {
|
||||||
|
guard hasConflict, readings.count >= 2 else { return nil }
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard let min = values.min(), let max = values.max(), min > 0 else { return nil }
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
|
||||||
|
if percentDiff < 5 { return .minor }
|
||||||
|
if percentDiff < 20 { return .moderate }
|
||||||
|
if percentDiff < 50 { return .significant }
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConflictThreshold {
|
||||||
|
let percentageThreshold: Double
|
||||||
|
let absoluteThreshold: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataGap: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let expectedRecordCount: Int
|
||||||
|
let actualRecordCount: Int
|
||||||
|
|
||||||
|
var severity: GapSeverity {
|
||||||
|
let duration = timeWindow.duration
|
||||||
|
if duration < 3600 { return .minor } // < 1 hour
|
||||||
|
if duration < 4 * 3600 { return .moderate } // < 4 hours
|
||||||
|
if duration < 12 * 3600 { return .significant } // < 12 hours
|
||||||
|
return .major
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GapSeverity {
|
||||||
|
case minor, moderate, significant, major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailySummary {
|
||||||
|
let date: Date
|
||||||
|
var values: [HealthDataType: Double] = [:]
|
||||||
|
var conflictCounts: [HealthDataType: Int] = [:]
|
||||||
|
var lastUpdated = Date()
|
||||||
|
|
||||||
|
var totalConflicts: Int {
|
||||||
|
conflictCounts.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedValue(for dataType: HealthDataType) -> String {
|
||||||
|
guard let value = values[dataType] else { return "–" }
|
||||||
|
|
||||||
|
switch dataType {
|
||||||
|
case .steps, .floorsClimbed:
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
case .distance:
|
||||||
|
return String(format: "%.2f km", value)
|
||||||
|
case .heartRate, .restingHeartRate, .respiratoryRate:
|
||||||
|
return String(format: "%.0f %@", value, dataType.unit)
|
||||||
|
case .bloodPressureSystolic, .bloodPressureDiastolic:
|
||||||
|
return String(format: "%.0f mmHg", value)
|
||||||
|
case .bloodOxygen:
|
||||||
|
return String(format: "%.0f%%", value * 100)
|
||||||
|
case .activeEnergy:
|
||||||
|
return String(format: "%.0f kcal", value)
|
||||||
|
case .sleep:
|
||||||
|
let hours = Int(value)
|
||||||
|
let minutes = Int((value - Double(hours)) * 60)
|
||||||
|
return "\(hours)h \(minutes)min"
|
||||||
|
case .heartRateVariability:
|
||||||
|
return String(format: "%.0f ms", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - HealthKit Manager
|
||||||
|
@MainActor
|
||||||
|
class HealthKitManager: ObservableObject {
|
||||||
|
static let shared = HealthKitManager()
|
||||||
|
|
||||||
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var authorizationStatus: [HealthDataType: HKAuthorizationStatus] = [:]
|
||||||
|
@Published var discoveredSources: [HealthSource] = []
|
||||||
|
@Published var sourceHealthStatus: [String: SourceHealthStatus] = [:]
|
||||||
|
@Published var lastError: Error?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
var allQuantityTypes: Set<HKQuantityType> {
|
||||||
|
var types = Set<HKQuantityType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
types.insert(quantityType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCategoryTypes: Set<HKCategoryType> {
|
||||||
|
var types = Set<HKCategoryType>()
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let categoryType = dataType.hkCategoryType {
|
||||||
|
types.insert(categoryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
var allSampleTypes: Set<HKSampleType> {
|
||||||
|
var types = Set<HKSampleType>()
|
||||||
|
allQuantityTypes.forEach { types.insert($0) }
|
||||||
|
allCategoryTypes.forEach { types.insert($0) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async throws {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
throw HealthKitError.healthDataNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let typesToRead = allSampleTypes
|
||||||
|
let typesToWrite = allQuantityTypes
|
||||||
|
|
||||||
|
try await healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead)
|
||||||
|
isAuthorized = true
|
||||||
|
|
||||||
|
await updateAuthorizationStatus()
|
||||||
|
await discoverSources()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAuthorizationStatus() async {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let status = healthStore.authorizationStatus(for: quantityType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
} else if let categoryType = dataType.hkCategoryType {
|
||||||
|
let status = healthStore.authorizationStatus(for: categoryType)
|
||||||
|
authorizationStatus[dataType] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
var allSources: [String: HealthSource] = [:]
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sources = try await fetchSources(for: sampleType)
|
||||||
|
for source in sources {
|
||||||
|
if var existingSource = allSources[source.bundleIdentifier] {
|
||||||
|
existingSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = existingSource
|
||||||
|
} else {
|
||||||
|
var newSource = source
|
||||||
|
newSource.supportedDataTypes.insert(dataType)
|
||||||
|
allSources[source.bundleIdentifier] = newSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch sources for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredSources = Array(allSources.values).sorted { $0.category.priority > $1.category.priority }
|
||||||
|
|
||||||
|
// Update source health status
|
||||||
|
for source in discoveredSources {
|
||||||
|
await updateSourceHealth(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSources(for sampleType: HKSampleType) async throws -> [HealthSource] {
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
// Handled via continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSourceQuery(sampleType: sampleType, samplePredicate: nil) { _, sourcesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let sources = sourcesOrNil else {
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthSources = sources.map { HealthSource.from(hkSource: $0) }
|
||||||
|
continuation.resume(returning: healthSources)
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSourceHealth(_ source: HealthSource) async {
|
||||||
|
var recordCount = 0
|
||||||
|
var lastActivity: Date?
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
if let quantityType = dataType.hkQuantityType {
|
||||||
|
let predicate = HKQuery.predicateForObjects(from: HKSource(bundleIdentifier: source.bundleIdentifier, name: source.name) )
|
||||||
|
// Simplified: just get count
|
||||||
|
if let count = try? await fetchRecordCount(for: quantityType, source: source) {
|
||||||
|
recordCount += count
|
||||||
|
}
|
||||||
|
if let date = try? await fetchLastActivityDate(for: quantityType, source: source) {
|
||||||
|
if lastActivity == nil || date > lastActivity! {
|
||||||
|
lastActivity = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = SourceHealthStatus(
|
||||||
|
id: source.id,
|
||||||
|
source: source,
|
||||||
|
lastSync: lastActivity,
|
||||||
|
recordCount: recordCount,
|
||||||
|
dataGaps: [], // TODO: Implement gap detection
|
||||||
|
overallQuality: recordCount > 0 ? .complete : .missing
|
||||||
|
)
|
||||||
|
|
||||||
|
sourceHealthStatus[source.id] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRecordCount(for sampleType: HKSampleType, source: HealthSource) async throws -> Int {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let startOfDay = calendar.startOfDay(for: now)
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now, options: .strictStartDate)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: predicate,
|
||||||
|
limit: HKObjectQueryNoLimit,
|
||||||
|
sortDescriptors: nil
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = samplesOrNil ?? []
|
||||||
|
let matchingSamples = samples.filter { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSamples.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLastActivityDate(for sampleType: HKSampleType, source: HealthSource) async throws -> Date? {
|
||||||
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let query = HKSampleQuery(
|
||||||
|
sampleType: sampleType,
|
||||||
|
predicate: nil,
|
||||||
|
limit: 1,
|
||||||
|
sortDescriptors: [sortDescriptor]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchingSample = samplesOrNil?.first { $0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier }
|
||||||
|
continuation.resume(returning: matchingSample?.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ source: HKSource) -> SourceCategory {
|
||||||
|
let bundleId = source.bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
if bundleId.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
} else if bundleId.contains("apple.health") && !bundleId.contains("watch") {
|
||||||
|
return .iPhone
|
||||||
|
} else if bundleId.contains("apple") && bundleId.contains("watch") {
|
||||||
|
return .watch
|
||||||
|
} else if bundleId.contains("huawei") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("samsung") || bundleId.contains("galaxy") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("fitbit") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("garmin") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("polar") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else if bundleId.contains("withings") {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
} else {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> Set<HealthDataType> {
|
||||||
|
return source.supportedDataTypes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Fetching (Basic)
|
||||||
|
|
||||||
|
func fetchSamples(
|
||||||
|
for dataType: HealthDataType,
|
||||||
|
from startDate: Date,
|
||||||
|
to endDate: Date
|
||||||
|
) async throws -> [HKSample] {
|
||||||
|
guard let sampleType = dataType.hkQuantityType ?? dataType.hkCategoryType else {
|
||||||
|
throw HealthKitError.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: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)]
|
||||||
|
) { _, samplesOrNil, errorOrNil in
|
||||||
|
if let error = errorOrNil {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: samplesOrNil ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.healthStore.execute(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Writing
|
||||||
|
|
||||||
|
func writeSample(
|
||||||
|
dataType: HealthDataType,
|
||||||
|
value: Double,
|
||||||
|
secondaryValue: Double? = nil,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let quantityType = dataType.hkQuantityType else {
|
||||||
|
throw HealthKitError.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBloodPressure(
|
||||||
|
systolic: Double,
|
||||||
|
diastolic: Double,
|
||||||
|
date: Date,
|
||||||
|
metadata: [String: Any]? = nil
|
||||||
|
) async throws {
|
||||||
|
guard let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic),
|
||||||
|
let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic) else {
|
||||||
|
throw HealthKitError.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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create correlation for blood pressure
|
||||||
|
guard let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure) else {
|
||||||
|
throw HealthKitError.unsupportedDataType
|
||||||
|
}
|
||||||
|
|
||||||
|
let correlation = HKCorrelation(
|
||||||
|
type: correlationType,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
objects: [systolicSample, diastolicSample],
|
||||||
|
metadata: metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
try await healthStore.save(correlation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit Errors
|
||||||
|
enum HealthKitError: LocalizedError {
|
||||||
|
case healthDataNotAvailable
|
||||||
|
case authorizationDenied
|
||||||
|
case unsupportedDataType
|
||||||
|
case noDataFound
|
||||||
|
case writeFailed(Error)
|
||||||
|
case queryFailed(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .healthDataNotAvailable:
|
||||||
|
return "Health-Daten sind auf diesem Gerät nicht verfügbar"
|
||||||
|
case .authorizationDenied:
|
||||||
|
return "Zugriff auf Health-Daten wurde verweigert"
|
||||||
|
case .unsupportedDataType:
|
||||||
|
return "Dieser Datentyp wird nicht unterstützt"
|
||||||
|
case .noDataFound:
|
||||||
|
return "Keine Daten gefunden"
|
||||||
|
case .writeFailed(let error):
|
||||||
|
return "Schreiben fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
case .queryFailed(let error):
|
||||||
|
return "Abfrage fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HKSource Extension
|
||||||
|
extension HKSource {
|
||||||
|
convenience init(bundleIdentifier: String, name: String) {
|
||||||
|
// Note: This is a workaround since HKSource doesn't have a public initializer
|
||||||
|
// In production, sources come from HealthKit queries
|
||||||
|
fatalError("HKSource cannot be initialized directly - use source from HKSample")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Merge Engine
|
||||||
|
@MainActor
|
||||||
|
class MergeEngine: ObservableObject {
|
||||||
|
static let shared = MergeEngine()
|
||||||
|
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
|
||||||
|
@Published var pendingMerges: [MergeOperation] = []
|
||||||
|
@Published var completedMerges: [MergeOperation] = []
|
||||||
|
@Published var isMerging = false
|
||||||
|
@Published var mergeProgress: Double = 0
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Analyze Window
|
||||||
|
|
||||||
|
func analyze(windowData: TimeWindowData) -> WindowAnalysis {
|
||||||
|
let readings = windowData.readings
|
||||||
|
let dataType = windowData.dataType
|
||||||
|
|
||||||
|
// No analysis needed for single reading
|
||||||
|
if readings.count <= 1 {
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: false,
|
||||||
|
conflictSeverity: nil,
|
||||||
|
recommendedReading: readings.first,
|
||||||
|
alternativeReadings: [],
|
||||||
|
confidence: .high,
|
||||||
|
analysisNotes: readings.isEmpty ? "Keine Daten" : "Einzelne Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rule to get recommendation
|
||||||
|
let result = ruleEngine.applyRule(to: readings, dataType: dataType)
|
||||||
|
|
||||||
|
// Calculate conflict severity
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
var severity: ConflictSeverity? = nil
|
||||||
|
|
||||||
|
if values.count >= 2, let min = values.min(), let max = values.max(), min > 0 {
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
if percentDiff >= 5 {
|
||||||
|
if percentDiff < 10 { severity = .minor }
|
||||||
|
else if percentDiff < 25 { severity = .moderate }
|
||||||
|
else if percentDiff < 50 { severity = .significant }
|
||||||
|
else { severity = .major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let alternativeReadings = readings.filter { $0.id != result.selectedReading?.id }
|
||||||
|
|
||||||
|
return WindowAnalysis(
|
||||||
|
windowData: windowData,
|
||||||
|
hasConflict: windowData.hasConflict,
|
||||||
|
conflictSeverity: severity,
|
||||||
|
recommendedReading: result.selectedReading,
|
||||||
|
alternativeReadings: alternativeReadings,
|
||||||
|
confidence: result.confidence,
|
||||||
|
analysisNotes: result.reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Resolve Conflict
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, using result: RuleApplicationResult) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = result.selectedReading else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: result.strategy,
|
||||||
|
isManual: result.strategy == .manual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConflictManually(
|
||||||
|
_ conflict: Conflict,
|
||||||
|
selectedReadingId: UUID
|
||||||
|
) -> ConflictResolution? {
|
||||||
|
guard let selectedReading = conflict.readings.first(where: { $0.id == selectedReadingId }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConflictResolution(
|
||||||
|
resolvedValue: selectedReading.value,
|
||||||
|
secondaryResolvedValue: selectedReading.secondaryValue,
|
||||||
|
winningSourceId: selectedReading.sourceId,
|
||||||
|
strategy: .manual,
|
||||||
|
isManual: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create Merged Record
|
||||||
|
|
||||||
|
func createMergedRecord(from conflict: Conflict, resolution: ConflictResolution) -> MergedRecord {
|
||||||
|
return MergedRecord(
|
||||||
|
id: UUID(),
|
||||||
|
dataType: conflict.dataType,
|
||||||
|
timeWindow: conflict.timeWindow,
|
||||||
|
value: resolution.resolvedValue,
|
||||||
|
secondaryValue: resolution.secondaryResolvedValue,
|
||||||
|
originalSourceId: resolution.winningSourceId,
|
||||||
|
strategy: resolution.strategy,
|
||||||
|
createdAt: Date(),
|
||||||
|
metadata: [
|
||||||
|
"conflictId": conflict.id.uuidString,
|
||||||
|
"originalSourceCount": String(conflict.readings.count),
|
||||||
|
"isManualResolution": String(resolution.isManual)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch Processing
|
||||||
|
|
||||||
|
func processConflicts(_ conflicts: [Conflict]) async -> [MergeOperation] {
|
||||||
|
isMerging = true
|
||||||
|
defer { isMerging = false }
|
||||||
|
|
||||||
|
var operations: [MergeOperation] = []
|
||||||
|
|
||||||
|
for (index, conflict) in conflicts.enumerated() {
|
||||||
|
mergeProgress = Double(index) / Double(conflicts.count)
|
||||||
|
|
||||||
|
let result = ruleEngine.applyRule(to: conflict.readings, dataType: conflict.dataType)
|
||||||
|
|
||||||
|
if result.confidence == .requiresManual ||
|
||||||
|
ruleEngine.shouldRequestManualReview(readings: conflict.readings, dataType: conflict.dataType) {
|
||||||
|
// Add to pending for manual review
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .pendingManualReview,
|
||||||
|
result: result
|
||||||
|
)
|
||||||
|
pendingMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
} else if let resolution = resolveConflict(conflict, using: result) {
|
||||||
|
// Auto-resolve
|
||||||
|
let mergedRecord = createMergedRecord(from: conflict, resolution: resolution)
|
||||||
|
let operation = MergeOperation(
|
||||||
|
conflict: conflict,
|
||||||
|
status: .resolved,
|
||||||
|
result: result,
|
||||||
|
resolution: resolution,
|
||||||
|
mergedRecord: mergedRecord
|
||||||
|
)
|
||||||
|
completedMerges.append(operation)
|
||||||
|
operations.append(operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeProgress = 1.0
|
||||||
|
return operations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Merge
|
||||||
|
|
||||||
|
func performDailyMerge(for date: Date) async throws -> DailyMergeReport {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let startOfDay = calendar.startOfDay(for: date)
|
||||||
|
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
|
||||||
|
|
||||||
|
var report = DailyMergeReport(date: date)
|
||||||
|
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
do {
|
||||||
|
let windowData = try await dataReader.fetchData(
|
||||||
|
for: dataType,
|
||||||
|
from: startOfDay,
|
||||||
|
to: endOfDay
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictWindows = windowData.filter { $0.hasConflict }
|
||||||
|
report.totalConflicts += conflictWindows.count
|
||||||
|
|
||||||
|
for window in conflictWindows {
|
||||||
|
let analysis = analyze(windowData: window)
|
||||||
|
|
||||||
|
if analysis.confidence == .requiresManual {
|
||||||
|
report.pendingManualReview += 1
|
||||||
|
} else {
|
||||||
|
report.autoResolved += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report.analysesByType[dataType, default: []].append(analysis)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
report.errors.append("Fehler bei \(dataType.displayName): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct WindowAnalysis {
|
||||||
|
let windowData: TimeWindowData
|
||||||
|
let hasConflict: Bool
|
||||||
|
let conflictSeverity: ConflictSeverity?
|
||||||
|
let recommendedReading: SourceReading?
|
||||||
|
let alternativeReadings: [SourceReading]
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let analysisNotes: String
|
||||||
|
|
||||||
|
var recommendedValue: Double? {
|
||||||
|
recommendedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueDifference: Double? {
|
||||||
|
guard let recommended = recommendedReading?.value,
|
||||||
|
let alternative = alternativeReadings.first?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return abs(recommended - alternative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergeOperation: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let conflict: Conflict
|
||||||
|
var status: MergeStatus
|
||||||
|
let result: RuleApplicationResult
|
||||||
|
var resolution: ConflictResolution?
|
||||||
|
var mergedRecord: MergedRecord?
|
||||||
|
let createdAt = Date()
|
||||||
|
var processedAt: Date?
|
||||||
|
|
||||||
|
enum MergeStatus {
|
||||||
|
case pending
|
||||||
|
case pendingManualReview
|
||||||
|
case resolved
|
||||||
|
case written
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MergedRecord: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let dataType: HealthDataType
|
||||||
|
let timeWindow: TimeWindow
|
||||||
|
let value: Double
|
||||||
|
let secondaryValue: Double?
|
||||||
|
let originalSourceId: String
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let createdAt: Date
|
||||||
|
var writtenAt: Date?
|
||||||
|
var healthKitRecordId: String?
|
||||||
|
var metadata: [String: String]
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
if value == floor(value) {
|
||||||
|
return String(format: "%.0f", value)
|
||||||
|
}
|
||||||
|
return String(format: "%.1f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DailyMergeReport {
|
||||||
|
let date: Date
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var analysesByType: [HealthDataType: [WindowAnalysis]] = [:]
|
||||||
|
var errors: [String] = []
|
||||||
|
let generatedAt = Date()
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary: String {
|
||||||
|
if totalConflicts == 0 {
|
||||||
|
return "Keine Konflikte gefunden"
|
||||||
|
}
|
||||||
|
return "\(autoResolved)/\(totalConflicts) automatisch gelöst, \(pendingManualReview) zur Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Rule Engine
|
||||||
|
@MainActor
|
||||||
|
class RuleEngine: ObservableObject {
|
||||||
|
static let shared = RuleEngine()
|
||||||
|
|
||||||
|
@Published var rules: [HealthDataType: MergeRule] = [:]
|
||||||
|
@Published var isLoaded = false
|
||||||
|
|
||||||
|
private let storage = RuleStorageManager()
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Loading
|
||||||
|
|
||||||
|
func loadRules() {
|
||||||
|
let savedRules = storage.loadRules()
|
||||||
|
|
||||||
|
if savedRules.isEmpty {
|
||||||
|
// Initialize with defaults
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
} else {
|
||||||
|
rules = savedRules
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRules() {
|
||||||
|
storage.saveRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Access
|
||||||
|
|
||||||
|
func getRule(for dataType: HealthDataType) -> MergeRule {
|
||||||
|
return rules[dataType] ?? MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRule(_ rule: MergeRule, for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = rule
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefault(for dataType: HealthDataType) {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllToDefaults() {
|
||||||
|
for dataType in HealthDataType.allCases {
|
||||||
|
rules[dataType] = MergeRule.defaultRule(for: dataType)
|
||||||
|
}
|
||||||
|
saveRules()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application
|
||||||
|
|
||||||
|
func applyRule(
|
||||||
|
to readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
// Filter out empty/zero readings for most strategies
|
||||||
|
let validReadings = readings.filter { $0.value > 0 || $0.quality == .complete }
|
||||||
|
|
||||||
|
guard !validReadings.isEmpty else {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine gültigen Werte vorhanden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one valid reading, no conflict
|
||||||
|
if validReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: validReadings[0],
|
||||||
|
strategy: rule.strategy,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Nur eine Quelle verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply strategy
|
||||||
|
switch rule.strategy {
|
||||||
|
case .exclusive:
|
||||||
|
return applyExclusiveStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .priority:
|
||||||
|
return applyPriorityStrategy(readings: validReadings, rule: rule, dataType: dataType)
|
||||||
|
|
||||||
|
case .higherWins:
|
||||||
|
return applyHigherWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .lowerWins:
|
||||||
|
return applyLowerWinsStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .average:
|
||||||
|
return applyAverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverage:
|
||||||
|
return applyCoverageStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .coverageThenHigher:
|
||||||
|
return applyCoverageThenHigherStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .mostRecent:
|
||||||
|
return applyMostRecentStrategy(readings: validReadings, rule: rule)
|
||||||
|
|
||||||
|
case .manual:
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .manual,
|
||||||
|
confidence: .requiresManual,
|
||||||
|
reason: "Manuelle Entscheidung erforderlich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Strategy Implementations
|
||||||
|
|
||||||
|
private func applyExclusiveStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// If primary source is specified, use it
|
||||||
|
if let primaryId = rule.primarySourceId,
|
||||||
|
let reading = readings.first(where: { $0.sourceId == primaryId }) {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: reading,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Exklusive Quelle: \(reading.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use highest priority source
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .exclusive,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine geeignete Quelle gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPriorityStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule,
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Sort by user-defined priority, then by category priority
|
||||||
|
let sorted = readings.sorted { r1, r2 in
|
||||||
|
let p1 = rule.sourcePriorities[r1.sourceId] ?? r1.sourceCategory.priority
|
||||||
|
let p2 = rule.sourcePriorities[r2.sourceId] ?? r2.sourceCategory.priority
|
||||||
|
return p1 > p2
|
||||||
|
}
|
||||||
|
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Höchste Priorität: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .priority,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Priorität gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyHigherWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value > $1.value }
|
||||||
|
|
||||||
|
if let highest = sorted.first {
|
||||||
|
// Check if there's a significant difference
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let spread = (values.max() ?? 0) - (values.min() ?? 0)
|
||||||
|
let avgValue = values.reduce(0, +) / Double(values.count)
|
||||||
|
let spreadPercent = avgValue > 0 ? (spread / avgValue) * 100 : 0
|
||||||
|
|
||||||
|
let confidence: RuleConfidence = spreadPercent < 10 ? .high : .medium
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: confidence,
|
||||||
|
reason: "Höchster Wert: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .higherWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyLowerWinsStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.value < $1.value }
|
||||||
|
|
||||||
|
if let lowest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: lowest,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Niedrigster Wert: \(lowest.formattedValue) von \(lowest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .lowerWins,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Werte zum Vergleich"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let values = readings.map { $0.value }
|
||||||
|
let average = values.reduce(0, +) / Double(values.count)
|
||||||
|
|
||||||
|
// Create a synthetic reading for the average
|
||||||
|
let syntheticReading = SourceReading(
|
||||||
|
sourceId: HealthBridgeConstants.bundleIdentifier,
|
||||||
|
sourceName: "Durchschnitt",
|
||||||
|
sourceCategory: .healthBridge,
|
||||||
|
value: average,
|
||||||
|
timestamp: readings.first?.timestamp ?? Date(),
|
||||||
|
quality: .complete
|
||||||
|
)
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: syntheticReading,
|
||||||
|
strategy: .average,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Durchschnitt aus \(readings.count) Quellen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// Prefer readings with complete quality
|
||||||
|
let completeReadings = readings.filter { $0.quality == .complete }
|
||||||
|
|
||||||
|
if completeReadings.count == 1 {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: completeReadings[0],
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit vollständigen Daten: \(completeReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple complete readings, fall back to priority
|
||||||
|
if !completeReadings.isEmpty {
|
||||||
|
let sorted = completeReadings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Mehrere Quellen verfügbar, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No complete readings, use any reading with highest priority
|
||||||
|
let sorted = readings.sorted { $0.sourceCategory.priority > $1.sourceCategory.priority }
|
||||||
|
if let first = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine vollständigen Daten, gewählt: \(first.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverage,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Quelle mit Daten gefunden"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCoverageThenHigherStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
// First check if one source has data and others don't (coverage)
|
||||||
|
let nonZeroReadings = readings.filter { $0.value > 0 }
|
||||||
|
let zeroReadings = readings.filter { $0.value == 0 }
|
||||||
|
|
||||||
|
// If only one source has data, it wins on coverage
|
||||||
|
if nonZeroReadings.count == 1 && !zeroReadings.isEmpty {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nonZeroReadings[0],
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Einzige Quelle mit Daten: \(nonZeroReadings[0].sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple sources have data, use higher wins
|
||||||
|
if nonZeroReadings.count > 1 {
|
||||||
|
let sorted = nonZeroReadings.sorted { $0.value > $1.value }
|
||||||
|
if let highest = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: highest,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .medium,
|
||||||
|
reason: "Höherer Wert bei Konflikt: \(highest.formattedValue) von \(highest.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
if let first = readings.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: first,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Fallback auf erste Quelle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .coverageThenHigher,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Daten verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMostRecentStrategy(
|
||||||
|
readings: [SourceReading],
|
||||||
|
rule: MergeRule
|
||||||
|
) -> RuleApplicationResult {
|
||||||
|
let sorted = readings.sorted { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
|
if let mostRecent = sorted.first {
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: mostRecent,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .high,
|
||||||
|
reason: "Neuester Wert von \(mostRecent.sourceName)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RuleApplicationResult(
|
||||||
|
selectedReading: nil,
|
||||||
|
strategy: .mostRecent,
|
||||||
|
confidence: .low,
|
||||||
|
reason: "Keine Zeitstempel verfügbar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Threshold Check
|
||||||
|
|
||||||
|
func shouldRequestManualReview(
|
||||||
|
readings: [SourceReading],
|
||||||
|
dataType: HealthDataType
|
||||||
|
) -> Bool {
|
||||||
|
let rule = getRule(for: dataType)
|
||||||
|
|
||||||
|
guard let threshold = rule.thresholdForManualReview else {
|
||||||
|
return rule.strategy == .manual
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = readings.map { $0.value }.filter { $0 > 0 }
|
||||||
|
guard values.count >= 2,
|
||||||
|
let min = values.min(),
|
||||||
|
let max = values.max(),
|
||||||
|
min > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let percentDiff = (max - min) / min * 100
|
||||||
|
return percentDiff > threshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Application Result
|
||||||
|
struct RuleApplicationResult {
|
||||||
|
let selectedReading: SourceReading?
|
||||||
|
let strategy: MergeStrategy
|
||||||
|
let confidence: RuleConfidence
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
var resolvedValue: Double? {
|
||||||
|
selectedReading?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
var winningSourceId: String? {
|
||||||
|
selectedReading?.sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleConfidence: String, Codable {
|
||||||
|
case high = "high"
|
||||||
|
case medium = "medium"
|
||||||
|
case low = "low"
|
||||||
|
case requiresManual = "requires_manual"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "Hohe Sicherheit"
|
||||||
|
case .medium: return "Mittlere Sicherheit"
|
||||||
|
case .low: return "Geringe Sicherheit"
|
||||||
|
case .requiresManual: return "Manuelle Prüfung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "checkmark.seal.fill"
|
||||||
|
case .medium: return "checkmark.seal"
|
||||||
|
case .low: return "questionmark.circle"
|
||||||
|
case .requiresManual: return "hand.raised.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rule Storage Manager
|
||||||
|
class RuleStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let rulesKey = "healthbridge.merge.rules"
|
||||||
|
|
||||||
|
func saveRules(_ rules: [HealthDataType: MergeRule]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(rules)
|
||||||
|
userDefaults.set(data, forKey: rulesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save rules: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRules() -> [HealthDataType: MergeRule] {
|
||||||
|
guard let data = userDefaults.data(forKey: rulesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([HealthDataType: MergeRule].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load rules: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blood Pressure Handler
|
||||||
|
class BloodPressureHandler {
|
||||||
|
static let shared = BloodPressureHandler()
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let issues: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(systolic: Double, diastolic: Double) -> ValidationResult {
|
||||||
|
var issues: [String] = []
|
||||||
|
|
||||||
|
// Range validation
|
||||||
|
if systolic < 70 || systolic > 200 {
|
||||||
|
issues.append("Systolischer Wert ausserhalb des Normalbereichs (70-200 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if diastolic < 40 || diastolic > 130 {
|
||||||
|
issues.append("Diastolischer Wert ausserhalb des Normalbereichs (40-130 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausibility check
|
||||||
|
if diastolic >= systolic {
|
||||||
|
issues.append("Diastolischer Wert muss kleiner als systolischer Wert sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic < 20 {
|
||||||
|
issues.append("Pulsdruck zu gering (< 20 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if systolic - diastolic > 100 {
|
||||||
|
issues.append("Pulsdruck zu hoch (> 100 mmHg)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult(isValid: issues.isEmpty, issues: issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBloodPressure(systolic: Double, diastolic: Double) -> BloodPressureClassification {
|
||||||
|
if systolic < 120 && diastolic < 80 {
|
||||||
|
return .normal
|
||||||
|
} else if systolic < 130 && diastolic < 80 {
|
||||||
|
return .elevated
|
||||||
|
} else if systolic < 140 || diastolic < 90 {
|
||||||
|
return .hypertensionStage1
|
||||||
|
} else if systolic < 180 || diastolic < 120 {
|
||||||
|
return .hypertensionStage2
|
||||||
|
} else {
|
||||||
|
return .hypertensiveCrisis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BloodPressureClassification: String {
|
||||||
|
case normal = "Normal"
|
||||||
|
case elevated = "Erhöht"
|
||||||
|
case hypertensionStage1 = "Bluthochdruck Stufe 1"
|
||||||
|
case hypertensionStage2 = "Bluthochdruck Stufe 2"
|
||||||
|
case hypertensiveCrisis = "Hypertensive Krise"
|
||||||
|
|
||||||
|
var color: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "green"
|
||||||
|
case .elevated: return "yellow"
|
||||||
|
case .hypertensionStage1: return "orange"
|
||||||
|
case .hypertensionStage2: return "red"
|
||||||
|
case .hypertensiveCrisis: return "purple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recommendation: String {
|
||||||
|
switch self {
|
||||||
|
case .normal:
|
||||||
|
return "Weiter so! Regelmässige Kontrolle empfohlen."
|
||||||
|
case .elevated:
|
||||||
|
return "Lebensstiländerungen empfohlen. Mehr Bewegung, weniger Salz."
|
||||||
|
case .hypertensionStage1:
|
||||||
|
return "Arztbesuch empfohlen. Möglicherweise Medikation erforderlich."
|
||||||
|
case .hypertensionStage2:
|
||||||
|
return "Zeitnaher Arztbesuch erforderlich. Medikation wahrscheinlich notwendig."
|
||||||
|
case .hypertensiveCrisis:
|
||||||
|
return "SOFORT medizinische Hilfe aufsuchen!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Source Manager
|
||||||
|
@MainActor
|
||||||
|
class SourceManager: ObservableObject {
|
||||||
|
static let shared = SourceManager()
|
||||||
|
|
||||||
|
@Published var sources: [HealthSource] = []
|
||||||
|
@Published var sourceProfiles: [String: SourceProfile] = [:]
|
||||||
|
@Published var isDiscovering = false
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let storage = SourceStorageManager()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Source Discovery
|
||||||
|
|
||||||
|
func discoverSources() async {
|
||||||
|
isDiscovering = true
|
||||||
|
defer { isDiscovering = false }
|
||||||
|
|
||||||
|
await healthKitManager.discoverSources()
|
||||||
|
sources = healthKitManager.discoveredSources
|
||||||
|
|
||||||
|
// Load saved source profiles and merge with discovered sources
|
||||||
|
let savedProfiles = storage.loadSourceProfiles()
|
||||||
|
for source in sources {
|
||||||
|
if let savedProfile = savedProfiles[source.bundleIdentifier] {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = savedProfile
|
||||||
|
} else {
|
||||||
|
sourceProfiles[source.bundleIdentifier] = SourceProfile(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Classification
|
||||||
|
|
||||||
|
func classifySource(_ bundleIdentifier: String) -> SourceCategory {
|
||||||
|
let lowercased = bundleIdentifier.lowercased()
|
||||||
|
|
||||||
|
// HealthBridge
|
||||||
|
if lowercased.contains("healthbridge") {
|
||||||
|
return .healthBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Devices
|
||||||
|
if lowercased.contains("com.apple") {
|
||||||
|
if lowercased.contains("watch") || lowercased.contains("nano") {
|
||||||
|
return .watch
|
||||||
|
}
|
||||||
|
if lowercased.contains("health") {
|
||||||
|
return .iPhone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Watch Brands
|
||||||
|
let watchBrands = ["huawei", "samsung", "galaxy", "fitbit", "garmin", "polar",
|
||||||
|
"withings", "amazfit", "xiaomi", "honor", "oppo", "suunto",
|
||||||
|
"coros", "whoop", "oura"]
|
||||||
|
for brand in watchBrands {
|
||||||
|
if lowercased.contains(brand) {
|
||||||
|
return .thirdPartyWatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known Health Apps
|
||||||
|
let healthApps = ["strava", "nike", "adidas", "runtastic", "runkeeper",
|
||||||
|
"mapmyrun", "endomondo", "myfitnesspal", "flo", "clue"]
|
||||||
|
for app in healthApps {
|
||||||
|
if lowercased.contains(app) {
|
||||||
|
return .thirdPartyApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
|
||||||
|
func getSourceCapabilities(_ source: HealthSource) -> SourceCapabilities {
|
||||||
|
let category = source.category
|
||||||
|
|
||||||
|
switch category {
|
||||||
|
case .iPhone:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: true,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: true,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .watch:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyWatch:
|
||||||
|
// Check for specific features based on name
|
||||||
|
let name = source.name.lowercased()
|
||||||
|
let isHuaweiD2 = name.contains("huawei") && (name.contains("d2") || name.contains("watch d"))
|
||||||
|
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: true,
|
||||||
|
canMeasureBloodPressure: isHuaweiD2, // Huawei Watch D2 has BP sensor
|
||||||
|
canMeasureBloodOxygen: true,
|
||||||
|
canMeasureSleep: true,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .thirdPartyApp:
|
||||||
|
return SourceCapabilities(
|
||||||
|
canMeasureSteps: true,
|
||||||
|
canMeasureDistance: true,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: true,
|
||||||
|
hasGPS: true,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .healthBridge, .unknown:
|
||||||
|
return SourceCapabilities.empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health
|
||||||
|
|
||||||
|
func getSourceHealth(_ source: HealthSource) async -> SourceHealthReport {
|
||||||
|
var dataGaps: [HealthDataType: [TimeWindow]] = [:]
|
||||||
|
var lastActivityByType: [HealthDataType: Date] = [:]
|
||||||
|
var recordCountByType: [HealthDataType: Int] = [:]
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
|
||||||
|
|
||||||
|
for dataType in source.supportedDataTypes {
|
||||||
|
do {
|
||||||
|
let samples = try await healthKitManager.fetchSamples(
|
||||||
|
for: dataType,
|
||||||
|
from: yesterday,
|
||||||
|
to: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let matchingSamples = samples.filter {
|
||||||
|
$0.sourceRevision.source.bundleIdentifier == source.bundleIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCountByType[dataType] = matchingSamples.count
|
||||||
|
|
||||||
|
if let lastSample = matchingSamples.last {
|
||||||
|
lastActivityByType[dataType] = lastSample.endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect gaps
|
||||||
|
let gaps = detectDataGaps(
|
||||||
|
in: matchingSamples,
|
||||||
|
from: yesterday,
|
||||||
|
to: now,
|
||||||
|
expectedInterval: 15 * 60 // 15 minutes
|
||||||
|
)
|
||||||
|
if !gaps.isEmpty {
|
||||||
|
dataGaps[dataType] = gaps
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error checking health for \(dataType): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
if recordCountByType.values.reduce(0, +) == 0 {
|
||||||
|
overallQuality = .missing
|
||||||
|
} else if dataGaps.isEmpty {
|
||||||
|
overallQuality = .complete
|
||||||
|
} else {
|
||||||
|
overallQuality = .partial
|
||||||
|
}
|
||||||
|
|
||||||
|
return SourceHealthReport(
|
||||||
|
source: source,
|
||||||
|
lastActivityByType: lastActivityByType,
|
||||||
|
recordCountByType: recordCountByType,
|
||||||
|
dataGaps: dataGaps,
|
||||||
|
overallQuality: overallQuality,
|
||||||
|
checkedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectDataGaps(
|
||||||
|
in samples: [HKSample],
|
||||||
|
from start: Date,
|
||||||
|
to end: Date,
|
||||||
|
expectedInterval: TimeInterval
|
||||||
|
) -> [TimeWindow] {
|
||||||
|
guard !samples.isEmpty else {
|
||||||
|
return [TimeWindow(start: start, end: end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
var gaps: [TimeWindow] = []
|
||||||
|
let sortedSamples = samples.sorted { $0.startDate < $1.startDate }
|
||||||
|
|
||||||
|
// Check gap at beginning
|
||||||
|
if let firstSample = sortedSamples.first,
|
||||||
|
firstSample.startDate.timeIntervalSince(start) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: start, end: firstSample.startDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gaps between samples
|
||||||
|
for i in 0..<(sortedSamples.count - 1) {
|
||||||
|
let currentEnd = sortedSamples[i].endDate
|
||||||
|
let nextStart = sortedSamples[i + 1].startDate
|
||||||
|
let gap = nextStart.timeIntervalSince(currentEnd)
|
||||||
|
|
||||||
|
if gap > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: currentEnd, end: nextStart))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gap at end
|
||||||
|
if let lastSample = sortedSamples.last,
|
||||||
|
end.timeIntervalSince(lastSample.endDate) > expectedInterval * 2 {
|
||||||
|
gaps.append(TimeWindow(start: lastSample.endDate, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Priority Management
|
||||||
|
|
||||||
|
func setPriority(_ priority: Int, for source: HealthSource, dataType: HealthDataType) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.priorities[dataType] = priority
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPriority(for source: HealthSource, dataType: HealthDataType) -> Int {
|
||||||
|
if let profile = sourceProfiles[source.bundleIdentifier],
|
||||||
|
let priority = profile.priorities[dataType] {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
return source.category.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSourcesByPriority(for dataType: HealthDataType) -> [HealthSource] {
|
||||||
|
return sources
|
||||||
|
.filter { $0.supportedDataTypes.contains(dataType) }
|
||||||
|
.sorted { getPriority(for: $0, dataType: dataType) > getPriority(for: $1, dataType: dataType) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Enable/Disable
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool, for source: HealthSource) {
|
||||||
|
guard var profile = sourceProfiles[source.bundleIdentifier] else { return }
|
||||||
|
profile.isEnabled = enabled
|
||||||
|
sourceProfiles[source.bundleIdentifier] = profile
|
||||||
|
storage.saveSourceProfiles(sourceProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEnabled(_ source: HealthSource) -> Bool {
|
||||||
|
return sourceProfiles[source.bundleIdentifier]?.isEnabled ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Profile
|
||||||
|
struct SourceProfile: Codable {
|
||||||
|
let bundleIdentifier: String
|
||||||
|
var priorities: [HealthDataType: Int]
|
||||||
|
var isEnabled: Bool
|
||||||
|
var customName: String?
|
||||||
|
var notes: String?
|
||||||
|
let addedAt: Date
|
||||||
|
|
||||||
|
init(source: HealthSource) {
|
||||||
|
self.bundleIdentifier = source.bundleIdentifier
|
||||||
|
self.priorities = [:]
|
||||||
|
self.isEnabled = true
|
||||||
|
self.customName = nil
|
||||||
|
self.notes = nil
|
||||||
|
self.addedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Capabilities
|
||||||
|
struct SourceCapabilities {
|
||||||
|
let canMeasureSteps: Bool
|
||||||
|
let canMeasureDistance: Bool
|
||||||
|
let canMeasureFloors: Bool
|
||||||
|
let canMeasureHeartRate: Bool
|
||||||
|
let canMeasureBloodPressure: Bool
|
||||||
|
let canMeasureBloodOxygen: Bool
|
||||||
|
let canMeasureSleep: Bool
|
||||||
|
let canMeasureWorkouts: Bool
|
||||||
|
let hasGPS: Bool
|
||||||
|
let hasBarometer: Bool
|
||||||
|
let hasAccelerometer: Bool
|
||||||
|
|
||||||
|
static let empty = SourceCapabilities(
|
||||||
|
canMeasureSteps: false,
|
||||||
|
canMeasureDistance: false,
|
||||||
|
canMeasureFloors: false,
|
||||||
|
canMeasureHeartRate: false,
|
||||||
|
canMeasureBloodPressure: false,
|
||||||
|
canMeasureBloodOxygen: false,
|
||||||
|
canMeasureSleep: false,
|
||||||
|
canMeasureWorkouts: false,
|
||||||
|
hasGPS: false,
|
||||||
|
hasBarometer: false,
|
||||||
|
hasAccelerometer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedDataTypes: Set<HealthDataType> {
|
||||||
|
var types = Set<HealthDataType>()
|
||||||
|
if canMeasureSteps { types.insert(.steps) }
|
||||||
|
if canMeasureDistance { types.insert(.distance) }
|
||||||
|
if canMeasureFloors { types.insert(.floorsClimbed) }
|
||||||
|
if canMeasureHeartRate {
|
||||||
|
types.insert(.heartRate)
|
||||||
|
types.insert(.restingHeartRate)
|
||||||
|
}
|
||||||
|
if canMeasureBloodPressure {
|
||||||
|
types.insert(.bloodPressureSystolic)
|
||||||
|
types.insert(.bloodPressureDiastolic)
|
||||||
|
}
|
||||||
|
if canMeasureBloodOxygen { types.insert(.bloodOxygen) }
|
||||||
|
if canMeasureSleep { types.insert(.sleep) }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Health Report
|
||||||
|
struct SourceHealthReport {
|
||||||
|
let source: HealthSource
|
||||||
|
let lastActivityByType: [HealthDataType: Date]
|
||||||
|
let recordCountByType: [HealthDataType: Int]
|
||||||
|
let dataGaps: [HealthDataType: [TimeWindow]]
|
||||||
|
let overallQuality: DataQuality
|
||||||
|
let checkedAt: Date
|
||||||
|
|
||||||
|
var lastOverallActivity: Date? {
|
||||||
|
lastActivityByType.values.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRecordCount: Int {
|
||||||
|
recordCountByType.values.reduce(0, +)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSignificantGaps: Bool {
|
||||||
|
dataGaps.values.flatMap { $0 }.contains { $0.duration > 3600 } // > 1 hour gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source Storage Manager
|
||||||
|
class SourceStorageManager {
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let profilesKey = "healthbridge.source.profiles"
|
||||||
|
|
||||||
|
func saveSourceProfiles(_ profiles: [String: SourceProfile]) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(profiles)
|
||||||
|
userDefaults.set(data, forKey: profilesKey)
|
||||||
|
} catch {
|
||||||
|
print("Failed to save source profiles: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSourceProfiles() -> [String: SourceProfile] {
|
||||||
|
guard let data = userDefaults.data(forKey: profilesKey) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SourceProfile].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load source profiles: \(error)")
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Sync Coordinator
|
||||||
|
@MainActor
|
||||||
|
class SyncCoordinator: ObservableObject {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
private let healthKitManager = HealthKitManager.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let ruleEngine = RuleEngine.shared
|
||||||
|
private let mergeEngine = MergeEngine.shared
|
||||||
|
private let dataWriter = DataWriter.shared
|
||||||
|
|
||||||
|
@Published var isSyncing = false
|
||||||
|
@Published var syncProgress: Double = 0
|
||||||
|
@Published var lastSyncDate: Date?
|
||||||
|
@Published var lastSyncResult: SyncResult?
|
||||||
|
@Published var pendingConflicts: [Conflict] = []
|
||||||
|
@Published var syncHistory: [SyncResult] = []
|
||||||
|
|
||||||
|
private let syncHistoryKey = "healthbridge.sync.history"
|
||||||
|
private let maxHistoryItems = 50
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main Sync
|
||||||
|
|
||||||
|
func performSync(
|
||||||
|
for date: Date = Date(),
|
||||||
|
dataTypes: [HealthDataType] = HealthDataType.allCases
|
||||||
|
) async throws {
|
||||||
|
guard !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
syncProgress = 0
|
||||||
|
defer { isSyncing = false }
|
||||||
|
|
||||||
|
let startTime = Date()
|
||||||
|
var result = SyncResult(startedAt: startTime)
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Step 1: Refresh sources (10%)
|
||||||
|
syncProgress = 0.05
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
syncProgress = 0.1
|
||||||
|
|
||||||
|
// Step 2: Detect conflicts (40%)
|
||||||
|
let conflicts = try await dataReader.detectConflicts(for: date, dataTypes: dataTypes)
|
||||||
|
result.totalConflicts = conflicts.count
|
||||||
|
syncProgress = 0.4
|
||||||
|
|
||||||
|
// Step 3: Process conflicts with merge engine (70%)
|
||||||
|
let operations = await mergeEngine.processConflicts(conflicts)
|
||||||
|
syncProgress = 0.7
|
||||||
|
|
||||||
|
let autoResolved = operations.filter { $0.status == .resolved }
|
||||||
|
let pendingManual = operations.filter { $0.status == .pendingManualReview }
|
||||||
|
|
||||||
|
result.autoResolved = autoResolved.count
|
||||||
|
result.pendingManualReview = pendingManual.count
|
||||||
|
|
||||||
|
// Update pending conflicts
|
||||||
|
pendingConflicts = pendingManual.map { $0.conflict }
|
||||||
|
|
||||||
|
// Step 4: Write resolved records (90%)
|
||||||
|
let recordsToWrite = autoResolved.compactMap { $0.mergedRecord }
|
||||||
|
if !recordsToWrite.isEmpty {
|
||||||
|
let writeResult = await dataWriter.writeBatch(recordsToWrite)
|
||||||
|
result.writtenRecords = writeResult.successCount
|
||||||
|
result.writeErrors = writeResult.failureCount
|
||||||
|
}
|
||||||
|
syncProgress = 0.9
|
||||||
|
|
||||||
|
// Step 5: Finalize (100%)
|
||||||
|
result.completedAt = Date()
|
||||||
|
result.status = .success
|
||||||
|
syncProgress = 1.0
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
result.status = .failed
|
||||||
|
result.error = error.localizedDescription
|
||||||
|
result.completedAt = Date()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSyncDate = Date()
|
||||||
|
lastSyncResult = result
|
||||||
|
addToHistory(result)
|
||||||
|
|
||||||
|
// Send notification if there are pending conflicts
|
||||||
|
if result.pendingManualReview > 0 {
|
||||||
|
await sendConflictNotification(count: result.pendingManualReview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Sync
|
||||||
|
|
||||||
|
func performQuickSync() async throws {
|
||||||
|
try await performSync(
|
||||||
|
for: Date(),
|
||||||
|
dataTypes: [.steps, .heartRate, .activeEnergy]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Specific Data Type
|
||||||
|
|
||||||
|
func syncDataType(_ dataType: HealthDataType, for date: Date = Date()) async throws {
|
||||||
|
try await performSync(for: date, dataTypes: [dataType])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual Conflict Resolution
|
||||||
|
|
||||||
|
func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async throws {
|
||||||
|
guard let resolution = mergeEngine.resolveConflictManually(conflict, selectedReadingId: selectedReadingId) else {
|
||||||
|
throw SyncError.resolutionFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedConflict = conflict
|
||||||
|
resolvedConflict.status = .resolved
|
||||||
|
resolvedConflict.resolution = resolution
|
||||||
|
resolvedConflict.resolvedAt = Date()
|
||||||
|
|
||||||
|
let mergedRecord = mergeEngine.createMergedRecord(from: resolvedConflict, resolution: resolution)
|
||||||
|
|
||||||
|
// Write the record
|
||||||
|
_ = try await dataWriter.writeRecord(mergedRecord)
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func ignoreConflict(_ conflict: Conflict) {
|
||||||
|
pendingConflicts.removeAll { $0.id == conflict.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync History
|
||||||
|
|
||||||
|
private func loadSyncHistory() {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: syncHistoryKey),
|
||||||
|
let history = try? JSONDecoder().decode([SyncResult].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncHistory = history
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addToHistory(_ result: SyncResult) {
|
||||||
|
syncHistory.insert(result, at: 0)
|
||||||
|
if syncHistory.count > maxHistoryItems {
|
||||||
|
syncHistory = Array(syncHistory.prefix(maxHistoryItems))
|
||||||
|
}
|
||||||
|
saveSyncHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveSyncHistory() {
|
||||||
|
guard let data = try? JSONEncoder().encode(syncHistory) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
syncHistory.removeAll()
|
||||||
|
UserDefaults.standard.removeObject(forKey: syncHistoryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
private func sendConflictNotification(count: Int) async {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
guard settings.authorizationStatus == .authorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
content.body = count == 1
|
||||||
|
? "1 Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
: "\(count) Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflicts",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermission() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
var todayStats: TodayStats {
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
let todaySyncs = syncHistory.filter {
|
||||||
|
Calendar.current.isDate($0.startedAt, inSameDayAs: today)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TodayStats(
|
||||||
|
syncCount: todaySyncs.count,
|
||||||
|
totalConflicts: todaySyncs.reduce(0) { $0 + $1.totalConflicts },
|
||||||
|
autoResolved: todaySyncs.reduce(0) { $0 + $1.autoResolved },
|
||||||
|
pendingManual: pendingConflicts.count,
|
||||||
|
lastSync: lastSyncDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct SyncResult: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let startedAt: Date
|
||||||
|
var completedAt: Date?
|
||||||
|
var status: SyncStatus = .inProgress
|
||||||
|
var totalConflicts = 0
|
||||||
|
var autoResolved = 0
|
||||||
|
var pendingManualReview = 0
|
||||||
|
var writtenRecords = 0
|
||||||
|
var writeErrors = 0
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
enum SyncStatus: String, Codable {
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case success = "success"
|
||||||
|
case partialSuccess = "partial_success"
|
||||||
|
case failed = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval? {
|
||||||
|
guard let completed = completedAt else { return nil }
|
||||||
|
return completed.timeIntervalSince(startedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedDuration: String {
|
||||||
|
guard let duration = duration else { return "–" }
|
||||||
|
if duration < 1 {
|
||||||
|
return "< 1s"
|
||||||
|
}
|
||||||
|
return String(format: "%.1fs", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
var successRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TodayStats {
|
||||||
|
let syncCount: Int
|
||||||
|
let totalConflicts: Int
|
||||||
|
let autoResolved: Int
|
||||||
|
let pendingManual: Int
|
||||||
|
let lastSync: Date?
|
||||||
|
|
||||||
|
var resolutionRate: Double {
|
||||||
|
guard totalConflicts > 0 else { return 1.0 }
|
||||||
|
return Double(autoResolved) / Double(totalConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedLastSync: String {
|
||||||
|
guard let date = lastSync else { return "Nie" }
|
||||||
|
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: date, relativeTo: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncError: LocalizedError {
|
||||||
|
case notAuthorized
|
||||||
|
case syncInProgress
|
||||||
|
case resolutionFailed
|
||||||
|
case writeFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorized:
|
||||||
|
return "Keine Berechtigung für HealthKit"
|
||||||
|
case .syncInProgress:
|
||||||
|
return "Synchronisierung läuft bereits"
|
||||||
|
case .resolutionFailed:
|
||||||
|
return "Konfliktauflösung fehlgeschlagen"
|
||||||
|
case .writeFailed:
|
||||||
|
return "Schreiben der Daten fehlgeschlagen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Date Extensions
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)!.addingTimeInterval(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isYesterday: Bool {
|
||||||
|
Calendar.current.isDateInYesterday(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedRelative() -> String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
return formatter.localizedString(for: self, relativeTo: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(days: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(hours: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .hour, value: hours, to: self)!
|
||||||
|
}
|
||||||
|
|
||||||
|
func adding(minutes: Int) -> Date {
|
||||||
|
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Double Extensions
|
||||||
|
extension Double {
|
||||||
|
func formatted(decimals: Int = 1) -> String {
|
||||||
|
String(format: "%.\(decimals)f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsInteger: String {
|
||||||
|
String(format: "%.0f", self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedAsPercentage: String {
|
||||||
|
String(format: "%.1f%%", self * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Array Extensions
|
||||||
|
extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Extensions
|
||||||
|
extension Color {
|
||||||
|
static let healthBridgePrimary = Color.blue
|
||||||
|
static let healthBridgeSecondary = Color.cyan
|
||||||
|
static let healthBridgeAccent = Color.orange
|
||||||
|
|
||||||
|
static func forSeverity(_ severity: ConflictSeverity) -> Color {
|
||||||
|
switch severity {
|
||||||
|
case .minor: return .green
|
||||||
|
case .moderate: return .yellow
|
||||||
|
case .significant: return .orange
|
||||||
|
case .major: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func forQuality(_ quality: DataQuality) -> Color {
|
||||||
|
switch quality {
|
||||||
|
case .complete: return .green
|
||||||
|
case .partial: return .yellow
|
||||||
|
case .missing: return .gray
|
||||||
|
case .invalid: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Extensions
|
||||||
|
extension View {
|
||||||
|
func cardStyle() -> some View {
|
||||||
|
self
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionHeader(_ title: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding Extensions
|
||||||
|
extension Binding {
|
||||||
|
func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
|
||||||
|
Binding(
|
||||||
|
get: { self.wrappedValue },
|
||||||
|
set: { newValue in
|
||||||
|
self.wrappedValue = newValue
|
||||||
|
handler(newValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional Extensions
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
var orEmpty: String {
|
||||||
|
self ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNilOrEmpty: Bool {
|
||||||
|
self?.isEmpty ?? true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collection Extensions
|
||||||
|
extension Collection {
|
||||||
|
var isNotEmpty: Bool {
|
||||||
|
!isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class NotificationManager: ObservableObject {
|
||||||
|
static let shared = NotificationManager()
|
||||||
|
|
||||||
|
@Published var isAuthorized = false
|
||||||
|
@Published var pendingNotifications: [String] = []
|
||||||
|
|
||||||
|
private let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
Task {
|
||||||
|
await checkAuthorization()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization
|
||||||
|
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
isAuthorized = granted
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
print("Notification authorization failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthorization() async {
|
||||||
|
let settings = await center.notificationSettings()
|
||||||
|
isAuthorized = settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Notifications
|
||||||
|
|
||||||
|
func sendConflictNotification(count: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge"
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
content.body = "1 neuer Konflikt erfordert Ihre Aufmerksamkeit"
|
||||||
|
} else {
|
||||||
|
content.body = "\(count) neue Konflikte erfordern Ihre Aufmerksamkeit"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.badge = NSNumber(value: count)
|
||||||
|
content.categoryIdentifier = "CONFLICT"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.conflict.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Notifications
|
||||||
|
|
||||||
|
func sendSyncCompleteNotification(
|
||||||
|
conflictsResolved: Int,
|
||||||
|
pendingConflicts: Int
|
||||||
|
) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync abgeschlossen"
|
||||||
|
|
||||||
|
if pendingConflicts > 0 {
|
||||||
|
content.body = "\(conflictsResolved) Konflikte gelöst, \(pendingConflicts) offen"
|
||||||
|
} else {
|
||||||
|
content.body = "Alle \(conflictsResolved) Konflikte wurden gelöst"
|
||||||
|
}
|
||||||
|
|
||||||
|
content.sound = .default
|
||||||
|
content.categoryIdentifier = "SYNC_COMPLETE"
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.sync.\(Date().timeIntervalSince1970)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to send notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scheduled Notifications
|
||||||
|
|
||||||
|
func scheduleReminder(at hour: Int, minute: Int) async {
|
||||||
|
guard isAuthorized else { return }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "HealthBridge Erinnerung"
|
||||||
|
content.body = "Vergessen Sie nicht, Ihre Gesundheitsdaten zu synchronisieren"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var dateComponents = DateComponents()
|
||||||
|
dateComponents.hour = hour
|
||||||
|
dateComponents.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "healthbridge.reminder.daily",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await center.add(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule reminder: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelReminder() {
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ["healthbridge.reminder.daily"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Management
|
||||||
|
|
||||||
|
func clearBadge() async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(0)
|
||||||
|
} catch {
|
||||||
|
print("Failed to clear badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBadge(count: Int) async {
|
||||||
|
do {
|
||||||
|
try await center.setBadgeCount(count)
|
||||||
|
} catch {
|
||||||
|
print("Failed to update badge: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification Categories
|
||||||
|
|
||||||
|
func registerCategories() {
|
||||||
|
// Conflict category with actions
|
||||||
|
let resolveAction = UNNotificationAction(
|
||||||
|
identifier: "RESOLVE_AUTO",
|
||||||
|
title: "Automatisch lösen",
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
let viewAction = UNNotificationAction(
|
||||||
|
identifier: "VIEW_CONFLICTS",
|
||||||
|
title: "Anzeigen",
|
||||||
|
options: [.foreground]
|
||||||
|
)
|
||||||
|
|
||||||
|
let conflictCategory = UNNotificationCategory(
|
||||||
|
identifier: "CONFLICT",
|
||||||
|
actions: [resolveAction, viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync complete category
|
||||||
|
let syncCategory = UNNotificationCategory(
|
||||||
|
identifier: "SYNC_COMPLETE",
|
||||||
|
actions: [viewAction],
|
||||||
|
intentIdentifiers: [],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
|
||||||
|
center.setNotificationCategories([conflictCategory, syncCategory])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DashboardViewModel: ObservableObject {
|
||||||
|
private let syncCoordinator = SyncCoordinator.shared
|
||||||
|
private let dataReader = DataReader.shared
|
||||||
|
private let sourceManager = SourceManager.shared
|
||||||
|
|
||||||
|
@Published var dailySummary: DailySummary?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var selectedDate = Date()
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
setupBindings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBindings() {
|
||||||
|
$selectedDate
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] date in
|
||||||
|
Task {
|
||||||
|
await self?.loadData(for: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData(for date: Date = Date()) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
dailySummary = try await dataReader.fetchDailySummary(for: date)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSync() async {
|
||||||
|
do {
|
||||||
|
try await syncCoordinator.performSync(for: selectedDate)
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Sync fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await sourceManager.discoverSources()
|
||||||
|
await loadData(for: selectedDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncStatus: SyncStatus {
|
||||||
|
if syncCoordinator.isSyncing {
|
||||||
|
return .syncing
|
||||||
|
} else if let lastSync = syncCoordinator.lastSyncDate {
|
||||||
|
let hoursSinceSync = Date().timeIntervalSince(lastSync) / 3600
|
||||||
|
if hoursSinceSync < 1 {
|
||||||
|
return .synced
|
||||||
|
} else if hoursSinceSync < 24 {
|
||||||
|
return .stale
|
||||||
|
} else {
|
||||||
|
return .veryStale
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .neverSynced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncStatus {
|
||||||
|
case syncing
|
||||||
|
case synced
|
||||||
|
case stale
|
||||||
|
case veryStale
|
||||||
|
case neverSynced
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "Synchronisiere..."
|
||||||
|
case .synced: return "Synchronisiert"
|
||||||
|
case .stale: return "Sync empfohlen"
|
||||||
|
case .veryStale: return "Sync überfällig"
|
||||||
|
case .neverSynced: return "Nie synchronisiert"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .syncing: return "arrow.triangle.2.circlepath"
|
||||||
|
case .synced: return "checkmark.circle.fill"
|
||||||
|
case .stale: return "exclamationmark.circle"
|
||||||
|
case .veryStale: return "exclamationmark.triangle"
|
||||||
|
case .neverSynced: return "xmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# Integration Guide für Aurora Livecam Erweiterungen
|
||||||
|
|
||||||
|
## Übersicht der neuen Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
aurora-livecam/
|
||||||
|
├── SettingsManager.php # Admin-Einstellungen Klasse
|
||||||
|
├── settings.json # Einstellungen Datei
|
||||||
|
├── js/
|
||||||
|
│ ├── timelapse-controls.js # Timelapse mit Slider
|
||||||
|
│ ├── video-player.js # Tagesvideos im Player
|
||||||
|
│ └── admin-settings.js # Admin AJAX
|
||||||
|
├── css/
|
||||||
|
│ └── player-controls.css # Styles für Controls
|
||||||
|
└── INTEGRATION.md # Diese Anleitung
|
||||||
|
```
|
||||||
|
|
||||||
|
## Änderungen in index.php
|
||||||
|
|
||||||
|
### 1. Am Anfang der Datei (nach den requires)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// ... bestehende requires ...
|
||||||
|
|
||||||
|
// NEU: Settings Manager einbinden
|
||||||
|
require_once 'SettingsManager.php';
|
||||||
|
$settingsManager = new SettingsManager();
|
||||||
|
|
||||||
|
// AJAX-Handler für Settings (VOR session_start!)
|
||||||
|
$settingsManager->handleAjax();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Im HEAD-Bereich (CSS einbinden)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="css/player-controls.css">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vor </body> (JavaScript einbinden)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="js/timelapse-controls.js"></script>
|
||||||
|
<script src="js/video-player.js"></script>
|
||||||
|
<?php if ($adminManager->isAdmin()): ?>
|
||||||
|
<script src="js/admin-settings.js"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Video-Container anpassen
|
||||||
|
|
||||||
|
Ersetze den bestehenden video-container:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="video-container">
|
||||||
|
<?php echo $webcamManager->displayWebcam(); ?>
|
||||||
|
|
||||||
|
<!-- Timelapse Overlay -->
|
||||||
|
<div id="timelapse-viewer" style="display: none;">
|
||||||
|
<img id="timelapse-image" src="" alt="Timelapse">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEU: Daily Video Player (wird dynamisch befüllt) -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEU: Timelapse Controls (außerhalb des Containers) -->
|
||||||
|
<div id="timelapse-controls"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Zuschauer-Anzeige konditionell machen
|
||||||
|
|
||||||
|
Ersetze die Viewer-Stat Anzeige:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$viewerCount = $viewerCounter->getInitialCount();
|
||||||
|
$showViewers = $settingsManager->shouldShowViewers($viewerCount);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($showViewers): ?>
|
||||||
|
<div class="info-badge viewer-stat">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
<strong id="viewer-count-display"><?php echo $viewerCount; ?></strong>
|
||||||
|
<span>Zuschauer</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Kalender Links anpassen
|
||||||
|
|
||||||
|
In der `VisualCalendarManager::displayVisualCalendar()` Methode:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Für Tagesvideos
|
||||||
|
$playInPlayer = $settingsManager->shouldPlayInPlayer();
|
||||||
|
$allowDownload = $settingsManager->shouldAllowDownload();
|
||||||
|
|
||||||
|
if ($playInPlayer) {
|
||||||
|
// Im Player abspielen
|
||||||
|
$output .= '<a href="#" onclick="DailyVideoPlayer.playVideo(\'' . $video['path'] . '\', ' . ($allowDownload ? 'true' : 'false') . '); return false;" class="play-link">';
|
||||||
|
$output .= '▶️ Abspielen';
|
||||||
|
$output .= '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($allowDownload) {
|
||||||
|
// Download Link
|
||||||
|
$output .= '<a href="?download_specific_video=..." class="download-link">⬇️ Download</a>';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Admin-Panel erweitern
|
||||||
|
|
||||||
|
Füge im Admin-Bereich hinzu:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php if ($adminManager->isAdmin()): ?>
|
||||||
|
<section id="admin" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Admin-Bereich</h2>
|
||||||
|
|
||||||
|
<!-- NEU: Settings Panel -->
|
||||||
|
<div id="admin-settings-panel">
|
||||||
|
<h3>⚙️ Anzeige-Einstellungen</h3>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>👥 Zuschauer-Anzeige</h4>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Zuschauer-Anzahl anzeigen</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-viewer-enabled"
|
||||||
|
<?php echo $settingsManager->get('viewer_display.enabled') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Mindestanzahl für Anzeige</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<input type="number" id="setting-min-viewers" class="number-input"
|
||||||
|
min="1" max="100"
|
||||||
|
value="<?php echo $settingsManager->get('viewer_display.min_viewers'); ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<h4>🎬 Video-Modus</h4>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Videos im Player abspielen</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-play-in-player"
|
||||||
|
<?php echo $settingsManager->get('video_mode.play_in_player') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Download erlauben</span>
|
||||||
|
<div class="setting-input">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="setting-allow-download"
|
||||||
|
<?php echo $settingsManager->get('video_mode.allow_download') ? 'checked' : ''; ?>>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bestehender Admin-Content -->
|
||||||
|
<?php echo $adminManager->displayAdminContent(); ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Timelapse Button Event anpassen
|
||||||
|
|
||||||
|
Im bestehenden JavaScript:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
timelapseButton.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (timelapseViewer.style.display === 'none') {
|
||||||
|
// NEU: TimelapseController verwenden
|
||||||
|
TimelapseController.init(imageFiles);
|
||||||
|
TimelapseController.show();
|
||||||
|
timelapseButton.textContent = 'Zurück zur Live-Webcam';
|
||||||
|
} else {
|
||||||
|
TimelapseController.backToLive();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Viewer Heartbeat anpassen
|
||||||
|
|
||||||
|
Im JavaScript für den Viewer-Counter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function updateViewerCount() {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams({action: 'viewer_heartbeat'})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const display = document.getElementById('viewer-count-display');
|
||||||
|
const container = document.querySelector('.viewer-stat');
|
||||||
|
|
||||||
|
if (data.count && display) {
|
||||||
|
display.textContent = data.count;
|
||||||
|
|
||||||
|
// Mindestanzahl prüfen (aus Settings)
|
||||||
|
const minViewers = window.minViewersToShow || 1;
|
||||||
|
if (container) {
|
||||||
|
container.style.display = data.count >= minViewers ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fertig!
|
||||||
|
|
||||||
|
Nach diesen Änderungen hast du:
|
||||||
|
- ✅ Timelapse mit Slider und 1x/10x/100x Geschwindigkeit
|
||||||
|
- ✅ Rückwärts-Spulen im Timelapse
|
||||||
|
- ✅ Tagesvideos im Player abspielen statt nur Download
|
||||||
|
- ✅ "Zurück zu Live" Button
|
||||||
|
- ✅ Admin-Einstellungen für Zuschauer-Anzeige
|
||||||
|
- ✅ Mindestanzahl für Zuschauer-Anzeige
|
||||||
|
- ✅ Video-Modus wählbar (Player/Download)
|
||||||
|
- ✅ Alles ohne Seiten-Reload
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SettingsManager - Verwaltet Admin-Einstellungen
|
||||||
|
* Speichert in settings.json, lädt ohne Reload
|
||||||
|
*/
|
||||||
|
class SettingsManager {
|
||||||
|
private $settingsFile;
|
||||||
|
private $settings = [];
|
||||||
|
|
||||||
|
public function __construct($file = null) {
|
||||||
|
$this->settingsFile = $file ?: (__DIR__ . '/settings.json');
|
||||||
|
$this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load() {
|
||||||
|
if (file_exists($this->settingsFile)) {
|
||||||
|
$content = file_get_contents($this->settingsFile);
|
||||||
|
$this->settings = json_decode($content, true) ?? $this->getDefaults();
|
||||||
|
} else {
|
||||||
|
$this->settings = $this->getDefaults();
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaults() {
|
||||||
|
return [
|
||||||
|
'viewer_display' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'min_viewers' => 1
|
||||||
|
],
|
||||||
|
'video_mode' => [
|
||||||
|
'play_in_player' => true,
|
||||||
|
'allow_download' => true
|
||||||
|
],
|
||||||
|
'timelapse' => [
|
||||||
|
'default_speed' => 1,
|
||||||
|
'available_speeds' => [1, 10, 100]
|
||||||
|
],
|
||||||
|
'last_updated' => null,
|
||||||
|
'updated_by' => null
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($key = null) {
|
||||||
|
if ($key === null) return $this->settings;
|
||||||
|
$keys = explode('.', $key);
|
||||||
|
$value = $this->settings;
|
||||||
|
foreach ($keys as $k) {
|
||||||
|
if (!isset($value[$k])) return null;
|
||||||
|
$value = $value[$k];
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set($key, $value) {
|
||||||
|
$keys = explode('.', $key);
|
||||||
|
$ref = &$this->settings;
|
||||||
|
foreach ($keys as $i => $k) {
|
||||||
|
if ($i === count($keys) - 1) {
|
||||||
|
$ref[$k] = $value;
|
||||||
|
} else {
|
||||||
|
if (!isset($ref[$k])) $ref[$k] = [];
|
||||||
|
$ref = &$ref[$k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->settings['last_updated'] = date('Y-m-d H:i:s');
|
||||||
|
return $this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function save() {
|
||||||
|
$payload = json_encode($this->settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($payload === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_put_contents($this->settingsFile, $payload, LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für AJAX-Anfragen
|
||||||
|
public function handleAjax() {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
|
||||||
|
if (!isset($_POST['settings_action'])) return;
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
switch ($_POST['settings_action']) {
|
||||||
|
case 'get':
|
||||||
|
echo json_encode(['success' => true, 'settings' => $this->settings]);
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'update':
|
||||||
|
$key = $_POST['key'] ?? null;
|
||||||
|
$value = $_POST['value'] ?? null;
|
||||||
|
|
||||||
|
// Boolean-Werte konvertieren
|
||||||
|
if ($value === 'true') $value = true;
|
||||||
|
if ($value === 'false') $value = false;
|
||||||
|
if (is_numeric($value)) $value = intval($value);
|
||||||
|
|
||||||
|
if ($key && $this->set($key, $value)) {
|
||||||
|
echo json_encode(['success' => true, 'message' => 'Einstellung gespeichert']);
|
||||||
|
} else {
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Fehler beim Speichern. Bitte Dateirechte prüfen.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewer-Anzeige prüfen
|
||||||
|
public function shouldShowViewers($currentCount) {
|
||||||
|
if (!$this->get('viewer_display.enabled')) return false;
|
||||||
|
return $currentCount >= $this->get('viewer_display.min_viewers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video-Modus prüfen
|
||||||
|
public function shouldPlayInPlayer() {
|
||||||
|
return $this->get('video_mode.play_in_player') === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldAllowDownload() {
|
||||||
|
return $this->get('video_mode.allow_download') === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/* ========== TIMELAPSE CONTROLS ========== */
|
||||||
|
#timelapse-controls {
|
||||||
|
display: none;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelapse-control-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn.active {
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-slider-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-slider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-time-display {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-speed-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
border-radius: 22px !important;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-back-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
border-radius: 22px !important;
|
||||||
|
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%) !important;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== DAILY VIDEO PLAYER ========== */
|
||||||
|
#daily-video-player {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
#daily-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== ADMIN SETTINGS PANEL ========== */
|
||||||
|
#admin-settings-panel {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#admin-settings-panel h3 {
|
||||||
|
color: #667eea;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group h4 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number Input */
|
||||||
|
.number-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== MOBILE RESPONSIVE ========== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.timelapse-control-bar {
|
||||||
|
padding: 10px 15px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-slider-container {
|
||||||
|
width: 100%;
|
||||||
|
order: 10;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tl-time-display {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Admin Settings Manager - AJAX ohne Reload
|
||||||
|
*/
|
||||||
|
const AdminSettings = {
|
||||||
|
settings: {},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.loadSettings();
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSettings: function() {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: 'settings_action=get'
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Settings load error:', err));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSetting: function(key, value) {
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `settings_action=update&key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('✓ Einstellung gespeichert', 'success');
|
||||||
|
// Sofort UI aktualisieren
|
||||||
|
this.applySettingImmediately(key, value);
|
||||||
|
} else {
|
||||||
|
this.showNotification('✗ Fehler beim Speichern', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Settings update error:', err);
|
||||||
|
this.showNotification('✗ Netzwerkfehler', 'error');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
applySettingImmediately: function(key, value) {
|
||||||
|
// Sofortige Anwendung ohne Reload
|
||||||
|
switch(key) {
|
||||||
|
case 'viewer_display.enabled':
|
||||||
|
const viewerEl = document.querySelector('.viewer-stat');
|
||||||
|
if (viewerEl) {
|
||||||
|
viewerEl.style.display = value === true || value === 'true' ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'viewer_display.min_viewers':
|
||||||
|
// Wird beim nächsten Heartbeat angewendet
|
||||||
|
window.minViewersToShow = parseInt(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUI: function() {
|
||||||
|
// Checkbox für Zuschauer-Anzeige
|
||||||
|
const viewerEnabled = document.getElementById('setting-viewer-enabled');
|
||||||
|
if (viewerEnabled) {
|
||||||
|
viewerEnabled.checked = this.settings.viewer_display?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestanzahl
|
||||||
|
const minViewers = document.getElementById('setting-min-viewers');
|
||||||
|
if (minViewers) {
|
||||||
|
minViewers.value = this.settings.viewer_display?.min_viewers ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video-Modus
|
||||||
|
const playInPlayer = document.getElementById('setting-play-in-player');
|
||||||
|
if (playInPlayer) {
|
||||||
|
playInPlayer.checked = this.settings.video_mode?.play_in_player ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowDownload = document.getElementById('setting-allow-download');
|
||||||
|
if (allowDownload) {
|
||||||
|
allowDownload.checked = this.settings.video_mode?.allow_download ?? true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners: function() {
|
||||||
|
// Zuschauer-Anzeige Toggle
|
||||||
|
document.getElementById('setting-viewer-enabled')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('viewer_display.enabled', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mindestanzahl Zuschauer
|
||||||
|
document.getElementById('setting-min-viewers')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('viewer_display.min_viewers', e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Video im Player abspielen
|
||||||
|
document.getElementById('setting-play-in-player')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('video_mode.play_in_player', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download erlauben
|
||||||
|
document.getElementById('setting-allow-download')?.addEventListener('change', (e) => {
|
||||||
|
this.updateSetting('video_mode.allow_download', e.target.checked);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification: function(message, type) {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `admin-notification ${type}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${type === 'success' ? '#4CAF50' : '#f44336'};
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
setTimeout(() => notification.remove(), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisierung nur im Admin-Bereich
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.getElementById('admin-settings-panel')) {
|
||||||
|
AdminSettings.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Timelapse Controller mit Slider, Geschwindigkeit und Rückwärts
|
||||||
|
*/
|
||||||
|
const TimelapseController = {
|
||||||
|
imageFiles: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
isReverse: false,
|
||||||
|
speed: 1,
|
||||||
|
availableSpeeds: [1, 10, 100],
|
||||||
|
intervalId: null,
|
||||||
|
baseInterval: 200, // ms bei 1x
|
||||||
|
|
||||||
|
init: function(imageFilesArray) {
|
||||||
|
this.imageFiles = imageFilesArray;
|
||||||
|
this.setupControls();
|
||||||
|
this.updateSlider();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupControls: function() {
|
||||||
|
const container = document.getElementById('timelapse-controls');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="timelapse-control-bar">
|
||||||
|
<button id="tl-play-pause" class="tl-btn" title="Play/Pause">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
<button id="tl-reverse" class="tl-btn" title="Rückwärts">
|
||||||
|
<i class="fas fa-backward"></i>
|
||||||
|
</button>
|
||||||
|
<div class="tl-slider-container">
|
||||||
|
<input type="range" id="tl-slider" min="0" max="100" value="0">
|
||||||
|
<span id="tl-time-display">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="tl-speed-container">
|
||||||
|
<button id="tl-speed" class="tl-btn tl-speed-btn">1x</button>
|
||||||
|
</div>
|
||||||
|
<button id="tl-back-live" class="tl-btn tl-back-btn" title="Zurück zu Live">
|
||||||
|
<i class="fas fa-video"></i> Live
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
document.getElementById('tl-play-pause').onclick = () => this.togglePlay();
|
||||||
|
document.getElementById('tl-reverse').onclick = () => this.toggleReverse();
|
||||||
|
document.getElementById('tl-speed').onclick = () => this.cycleSpeed();
|
||||||
|
document.getElementById('tl-back-live').onclick = () => this.backToLive();
|
||||||
|
|
||||||
|
const slider = document.getElementById('tl-slider');
|
||||||
|
slider.max = this.imageFiles.length - 1;
|
||||||
|
slider.oninput = (e) => this.seekTo(parseInt(e.target.value));
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePlay: function() {
|
||||||
|
this.isPlaying = !this.isPlaying;
|
||||||
|
const btn = document.getElementById('tl-play-pause');
|
||||||
|
btn.innerHTML = this.isPlaying ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.startPlayback();
|
||||||
|
} else {
|
||||||
|
this.stopPlayback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleReverse: function() {
|
||||||
|
this.isReverse = !this.isReverse;
|
||||||
|
const btn = document.getElementById('tl-reverse');
|
||||||
|
btn.classList.toggle('active', this.isReverse);
|
||||||
|
btn.innerHTML = this.isReverse ?
|
||||||
|
'<i class="fas fa-forward"></i>' :
|
||||||
|
'<i class="fas fa-backward"></i>';
|
||||||
|
},
|
||||||
|
|
||||||
|
cycleSpeed: function() {
|
||||||
|
const idx = this.availableSpeeds.indexOf(this.speed);
|
||||||
|
this.speed = this.availableSpeeds[(idx + 1) % this.availableSpeeds.length];
|
||||||
|
document.getElementById('tl-speed').textContent = this.speed + 'x';
|
||||||
|
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.stopPlayback();
|
||||||
|
this.startPlayback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPlayback: function() {
|
||||||
|
const interval = this.baseInterval / this.speed;
|
||||||
|
this.intervalId = setInterval(() => this.nextFrame(), interval);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPlayback: function() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
nextFrame: function() {
|
||||||
|
if (this.isReverse) {
|
||||||
|
this.currentIndex--;
|
||||||
|
if (this.currentIndex < 0) this.currentIndex = this.imageFiles.length - 1;
|
||||||
|
} else {
|
||||||
|
this.currentIndex++;
|
||||||
|
if (this.currentIndex >= this.imageFiles.length) this.currentIndex = 0;
|
||||||
|
}
|
||||||
|
this.showFrame(this.currentIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
seekTo: function(index) {
|
||||||
|
this.currentIndex = index;
|
||||||
|
this.showFrame(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
showFrame: function(index) {
|
||||||
|
const img = document.getElementById('timelapse-image');
|
||||||
|
if (img && this.imageFiles[index]) {
|
||||||
|
img.src = this.imageFiles[index];
|
||||||
|
}
|
||||||
|
this.updateSlider();
|
||||||
|
this.updateTimeDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSlider: function() {
|
||||||
|
const slider = document.getElementById('tl-slider');
|
||||||
|
if (slider) slider.value = this.currentIndex;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimeDisplay: function() {
|
||||||
|
const display = document.getElementById('tl-time-display');
|
||||||
|
if (!display || !this.imageFiles[this.currentIndex]) return;
|
||||||
|
|
||||||
|
const filename = this.imageFiles[this.currentIndex];
|
||||||
|
const match = filename.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const [_, y, m, d, h, min, s] = match;
|
||||||
|
display.textContent = `${d}.${m}.${y} ${h}:${min}:${s}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backToLive: function() {
|
||||||
|
this.stopPlayback();
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
// Live-Video wieder anzeigen
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'none';
|
||||||
|
document.getElementById('webcam-player').style.display = 'block';
|
||||||
|
document.getElementById('timelapse-button').textContent = 'Wochenzeitraffer';
|
||||||
|
|
||||||
|
// Controls verstecken
|
||||||
|
const controls = document.getElementById('timelapse-controls');
|
||||||
|
if (controls) controls.style.display = 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
show: function() {
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'block';
|
||||||
|
document.getElementById('webcam-player').style.display = 'none';
|
||||||
|
document.getElementById('daily-video-player').style.display = 'none';
|
||||||
|
|
||||||
|
const controls = document.getElementById('timelapse-controls');
|
||||||
|
if (controls) controls.style.display = 'block';
|
||||||
|
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.showFrame(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Daily Video Player - Spielt Tagesvideos im Hauptfenster ab
|
||||||
|
*/
|
||||||
|
const DailyVideoPlayer = {
|
||||||
|
currentVideo: null,
|
||||||
|
videoElement: null,
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.createPlayerElement();
|
||||||
|
this.setupEventListeners();
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerElement: function() {
|
||||||
|
// Player-Container erstellen falls nicht vorhanden
|
||||||
|
if (document.getElementById('daily-video-player')) return;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'daily-video-player';
|
||||||
|
container.style.display = 'none';
|
||||||
|
container.innerHTML = `
|
||||||
|
<video id="daily-video" controls playsinline>
|
||||||
|
<source src="" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
<div class="video-player-controls">
|
||||||
|
<button id="dvp-back-live" class="tl-btn tl-back-btn">
|
||||||
|
<i class="fas fa-video"></i> Zurück zu Live
|
||||||
|
</button>
|
||||||
|
<a id="dvp-download" class="button" style="display:none;">
|
||||||
|
<i class="fas fa-download"></i> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Nach dem Webcam-Player einfügen
|
||||||
|
const videoContainer = document.querySelector('.video-container');
|
||||||
|
if (videoContainer) {
|
||||||
|
videoContainer.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoElement = document.getElementById('daily-video');
|
||||||
|
},
|
||||||
|
|
||||||
|
setupEventListeners: function() {
|
||||||
|
document.getElementById('dvp-back-live')?.addEventListener('click', () => this.backToLive());
|
||||||
|
|
||||||
|
// Video-Ende Event
|
||||||
|
this.videoElement?.addEventListener('ended', () => {
|
||||||
|
// Optional: Automatisch zurück zu Live
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
playVideo: function(videoPath, allowDownload = true) {
|
||||||
|
this.currentVideo = videoPath;
|
||||||
|
|
||||||
|
// Andere Player verstecken
|
||||||
|
document.getElementById('webcam-player').style.display = 'none';
|
||||||
|
document.getElementById('timelapse-viewer').style.display = 'none';
|
||||||
|
document.getElementById('timelapse-controls')?.style.display = 'none';
|
||||||
|
|
||||||
|
// Diesen Player anzeigen
|
||||||
|
const player = document.getElementById('daily-video-player');
|
||||||
|
player.style.display = 'block';
|
||||||
|
|
||||||
|
// Video laden
|
||||||
|
this.videoElement.src = videoPath;
|
||||||
|
this.videoElement.load();
|
||||||
|
this.videoElement.play();
|
||||||
|
|
||||||
|
// Download-Button
|
||||||
|
const downloadBtn = document.getElementById('dvp-download');
|
||||||
|
if (allowDownload && downloadBtn) {
|
||||||
|
downloadBtn.style.display = 'inline-block';
|
||||||
|
downloadBtn.href = videoPath;
|
||||||
|
downloadBtn.download = videoPath.split('/').pop();
|
||||||
|
} else if (downloadBtn) {
|
||||||
|
downloadBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
backToLive: function() {
|
||||||
|
// Video stoppen
|
||||||
|
if (this.videoElement) {
|
||||||
|
this.videoElement.pause();
|
||||||
|
this.videoElement.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player verstecken
|
||||||
|
document.getElementById('daily-video-player').style.display = 'none';
|
||||||
|
|
||||||
|
// Live-Stream anzeigen
|
||||||
|
document.getElementById('webcam-player').style.display = 'block';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wird vom Kalender aufgerufen
|
||||||
|
handleCalendarClick: function(videoPath, playInPlayer, allowDownload) {
|
||||||
|
if (playInPlayer) {
|
||||||
|
this.playVideo(videoPath, allowDownload);
|
||||||
|
} else {
|
||||||
|
// Nur Download
|
||||||
|
window.location.href = videoPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialisierung
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
DailyVideoPlayer.init();
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"viewer_display": {
|
||||||
|
"enabled": true,
|
||||||
|
"min_viewers": 1
|
||||||
|
},
|
||||||
|
"video_mode": {
|
||||||
|
"play_in_player": true,
|
||||||
|
"allow_download": true
|
||||||
|
},
|
||||||
|
"timelapse": {
|
||||||
|
"default_speed": 1,
|
||||||
|
"available_speeds": [1, 10, 100]
|
||||||
|
},
|
||||||
|
"last_updated": null,
|
||||||
|
"updated_by": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.dctp_backups/
|
||||||
|
.dctp_settings.json
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Du generierst Code im DCTP-Format fuer effiziente Uebertragung.
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
1. **Zeilennummern am Ende jeder Zeile** im passenden Kommentar-Format
|
||||||
|
2. **Immer mit ###FILE: beginnen** bei jedem Codeblock
|
||||||
|
3. **Bei Korrekturen NUR die geaenderten Zeilen senden**, nie den ganzen File
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
- Python/Shell: `code #Z1`
|
||||||
|
- JavaScript/Java/C/C++: `code //Z1`
|
||||||
|
- HTML: `code <!--Z1-->`
|
||||||
|
- CSS: `code /*Z1*/`
|
||||||
|
- SQL: `code --Z1`
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Syntax | Beschreibung |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| `###FILE:` | `###FILE:pfad/datei.ext` | Datei angeben |
|
||||||
|
| `###NEW` | | Neue Datei, kompletter Inhalt folgt |
|
||||||
|
| `###DELETE:` | `###DELETE:Z5-Z12` | Zeilen 5-12 loeschen |
|
||||||
|
| `###INSERT_AFTER:` | `###INSERT_AFTER:Z5` | Nach Zeile 5 einfuegen |
|
||||||
|
| `###REPLACE:` | `###REPLACE:Z5-Z8` | Zeilen 5-8 ersetzen |
|
||||||
|
| `###END` | | Ende des Blocks |
|
||||||
|
| `###RENUMBER` | | Zeilennummern neu berechnen |
|
||||||
|
| `###CHECKSUM:` | `###CHECKSUM:a3f2b8c1` | Optional: Hash zur Validierung |
|
||||||
|
|
||||||
|
## Beispiel: Neue Datei
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Korrektur (REPLACE)
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
"""Multipliziert zwei Zahlen.""" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beispiel: Mehrere Dateien
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/models/user.py
|
||||||
|
###NEW
|
||||||
|
class User: #Z1
|
||||||
|
def __init__(self, name: str): #Z2
|
||||||
|
self.name = name #Z3
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/models/order.py
|
||||||
|
###NEW
|
||||||
|
from .user import User #Z1
|
||||||
|
#Z2
|
||||||
|
class Order: #Z3
|
||||||
|
def __init__(self, user: User): #Z4
|
||||||
|
self.user = user #Z5
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtig
|
||||||
|
|
||||||
|
- Bei Korrekturen: NUR Delta senden, nie kompletten File
|
||||||
|
- Nach INSERT/DELETE/REPLACE immer ###RENUMBER
|
||||||
|
- Leerzeilen auch nummerieren
|
||||||
|
- Zeilennummern werden beim Schreiben automatisch entfernt
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
# DCTP - Delta Code Transfer Protocol
|
||||||
|
|
||||||
|
Ein Tool um KI-generierten Code effizient in lokale Dateien zu uebertragen. Statt bei jeder Korrektur den kompletten Code neu zu senden, werden nur Aenderungen (Deltas) uebertragen.
|
||||||
|
|
||||||
|
## Das Problem
|
||||||
|
|
||||||
|
Claude generiert 500 Zeilen Code. Eine kleine Korrektur = nochmal 500 Zeilen. Verschwendung.
|
||||||
|
|
||||||
|
## Die Loesung
|
||||||
|
|
||||||
|
Zeilennummerierter Code + Steueranweisungen fuer gezielte Aenderungen.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requirements installieren
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# GUI starten
|
||||||
|
python dctp_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### 1. Projektverzeichnis waehlen
|
||||||
|
|
||||||
|
Klicke auf "Waehlen" und waehle dein Projektverzeichnis.
|
||||||
|
|
||||||
|
### 2. KI-Output einfuegen
|
||||||
|
|
||||||
|
Kopiere den DCTP-formatierten Output aus deinem Claude-Chat in das Input-Feld.
|
||||||
|
|
||||||
|
### 3. Analysieren
|
||||||
|
|
||||||
|
Klicke "Analysieren" um eine Vorschau der Operationen zu sehen.
|
||||||
|
|
||||||
|
### 4. Ausfuehren
|
||||||
|
|
||||||
|
Klicke "Ausfuehren" um die Aenderungen auf deine Dateien anzuwenden.
|
||||||
|
|
||||||
|
## DCTP-Format
|
||||||
|
|
||||||
|
### Neue Datei erstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
###END
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen ersetzen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z1-Z2
|
||||||
|
def add(a: int, b: int) -> int: #Z1
|
||||||
|
"""Addiert zwei Zahlen.""" #Z2
|
||||||
|
return a + b #Z3
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen einfuegen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zeilen loeschen
|
||||||
|
|
||||||
|
```
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zeilennummern-Format
|
||||||
|
|
||||||
|
Die Zeilennummern werden automatisch entsprechend der Programmiersprache formatiert:
|
||||||
|
|
||||||
|
| Sprache | Format | Beispiel |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Python | `#Z1` | `code #Z1` |
|
||||||
|
| JavaScript | `//Z1` | `code //Z1` |
|
||||||
|
| HTML | `<!--Z1-->` | `code <!--Z1-->` |
|
||||||
|
| CSS | `/*Z1*/` | `code /*Z1*/` |
|
||||||
|
| SQL | `--Z1` | `code --Z1` |
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
| Befehl | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `###FILE:pfad` | Zieldatei angeben |
|
||||||
|
| `###NEW` | Neue Datei erstellen |
|
||||||
|
| `###DELETE:Z5-Z12` | Zeilen loeschen |
|
||||||
|
| `###INSERT_AFTER:Z5` | Nach Zeile einfuegen |
|
||||||
|
| `###REPLACE:Z5-Z8` | Zeilen ersetzen |
|
||||||
|
| `###END` | Block beenden |
|
||||||
|
| `###RENUMBER` | Zeilennummern aktualisieren |
|
||||||
|
| `###CHECKSUM:hash` | Datei-Hash validieren |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Vorschau**: Zeigt was passieren wird, bevor es ausgefuehrt wird
|
||||||
|
- **Diff-Ansicht**: Zeigt Aenderungen farbig markiert (alt vs neu)
|
||||||
|
- **Undo**: Stellt den letzten Zustand wieder her
|
||||||
|
- **Backup**: Automatische Backups vor jeder Aenderung
|
||||||
|
- **Multi-File**: Mehrere Dateien in einem Durchgang bearbeiten
|
||||||
|
- **Checksum**: Optionale Validierung gegen externe Aenderungen
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|
- **Projektpfad**: Standard-Projektverzeichnis
|
||||||
|
- **Backup-Verzeichnis**: Wo Backups gespeichert werden
|
||||||
|
- **Auto-Renumber**: Zeilennummern automatisch aktualisieren
|
||||||
|
- **Checksum-Validierung**: Externe Aenderungen erkennen
|
||||||
|
- **Theme**: Hell oder dunkel
|
||||||
|
|
||||||
|
## Claude-Integration
|
||||||
|
|
||||||
|
Kopiere den Inhalt von `CLAUDE.md` in deine Claude-Chats (als Custom Instructions oder am Anfang des Gespraechs), damit Claude im DCTP-Format antwortet.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
dctp/
|
||||||
|
├── dctp_gui.py # Hauptfenster (CustomTkinter)
|
||||||
|
├── dctp_parser.py # Core-Logik: parse Steueranweisungen
|
||||||
|
├── dctp_executor.py # Fuehrt Operationen aus
|
||||||
|
├── dctp_backup.py # Undo/Backup-Verwaltung
|
||||||
|
├── dctp_diff.py # Diff-Berechnung fuer Vorschau
|
||||||
|
├── requirements.txt # Dependencies
|
||||||
|
├── CLAUDE.md # Anweisung fuer KI
|
||||||
|
└── README.md # Diese Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
DCTP Backup Manager - Handles backup and undo functionality.
|
||||||
|
|
||||||
|
Creates timestamped backups before operations and supports
|
||||||
|
restoring files to their previous state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileBackup:
|
||||||
|
"""Represents a single file backup."""
|
||||||
|
original: str
|
||||||
|
backup: str
|
||||||
|
existed: bool # Whether the file existed before (for new file handling)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupSession:
|
||||||
|
"""Represents a backup session (one execution run)."""
|
||||||
|
timestamp: str
|
||||||
|
files: list[FileBackup]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupInfo:
|
||||||
|
"""Info about a backup for display purposes."""
|
||||||
|
timestamp: str
|
||||||
|
file_count: int
|
||||||
|
files: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
|
"""Manages file backups for undo functionality."""
|
||||||
|
|
||||||
|
BACKUP_DIR_NAME = ".dctp_backups"
|
||||||
|
MANIFEST_FILE = "manifest.json"
|
||||||
|
MAX_SESSIONS = 50 # Keep last 50 sessions
|
||||||
|
|
||||||
|
def __init__(self, project_path: str):
|
||||||
|
"""
|
||||||
|
Initialize backup manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_dir = self.project_path / self.BACKUP_DIR_NAME
|
||||||
|
self.manifest_path = self.backup_dir / self.MANIFEST_FILE
|
||||||
|
self._current_session: Optional[BackupSession] = None
|
||||||
|
self._ensure_backup_dir()
|
||||||
|
|
||||||
|
def _ensure_backup_dir(self) -> None:
|
||||||
|
"""Create backup directory if it doesn't exist."""
|
||||||
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create .gitignore in backup dir
|
||||||
|
gitignore_path = self.backup_dir / ".gitignore"
|
||||||
|
if not gitignore_path.exists():
|
||||||
|
gitignore_path.write_text("*\n")
|
||||||
|
|
||||||
|
def _load_manifest(self) -> dict:
|
||||||
|
"""Load the manifest file."""
|
||||||
|
if self.manifest_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(self.manifest_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"sessions": []}
|
||||||
|
return {"sessions": []}
|
||||||
|
|
||||||
|
def _save_manifest(self, manifest: dict) -> None:
|
||||||
|
"""Save the manifest file."""
|
||||||
|
self.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
def start_session(self) -> None:
|
||||||
|
"""Start a new backup session."""
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
self._current_session = BackupSession(timestamp=timestamp, files=[])
|
||||||
|
|
||||||
|
def backup(self, file_path: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Create a backup of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file (relative to project or absolute)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Backup filename if successful, None if file doesn't exist
|
||||||
|
"""
|
||||||
|
if self._current_session is None:
|
||||||
|
self.start_session()
|
||||||
|
|
||||||
|
# Normalize path
|
||||||
|
if os.path.isabs(file_path):
|
||||||
|
full_path = Path(file_path)
|
||||||
|
rel_path = full_path.relative_to(self.project_path)
|
||||||
|
else:
|
||||||
|
rel_path = Path(file_path)
|
||||||
|
full_path = self.project_path / rel_path
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
existed = full_path.exists()
|
||||||
|
|
||||||
|
if existed:
|
||||||
|
# Generate backup filename
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||||
|
safe_name = str(rel_path).replace(os.sep, "_").replace("/", "_")
|
||||||
|
backup_name = f"{timestamp}_{safe_name}"
|
||||||
|
|
||||||
|
# Copy file to backup
|
||||||
|
backup_path = self.backup_dir / backup_name
|
||||||
|
shutil.copy2(full_path, backup_path)
|
||||||
|
else:
|
||||||
|
backup_name = ""
|
||||||
|
|
||||||
|
# Add to current session
|
||||||
|
self._current_session.files.append(FileBackup(
|
||||||
|
original=str(rel_path),
|
||||||
|
backup=backup_name,
|
||||||
|
existed=existed
|
||||||
|
))
|
||||||
|
|
||||||
|
return backup_name if existed else None
|
||||||
|
|
||||||
|
def end_session(self) -> None:
|
||||||
|
"""End the current backup session and save to manifest."""
|
||||||
|
if self._current_session is None or len(self._current_session.files) == 0:
|
||||||
|
self._current_session = None
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
# Convert to dict for JSON storage
|
||||||
|
session_dict = {
|
||||||
|
"timestamp": self._current_session.timestamp,
|
||||||
|
"files": [asdict(f) for f in self._current_session.files]
|
||||||
|
}
|
||||||
|
manifest["sessions"].append(session_dict)
|
||||||
|
|
||||||
|
# Limit number of sessions
|
||||||
|
if len(manifest["sessions"]) > self.MAX_SESSIONS:
|
||||||
|
# Remove old sessions and their backup files
|
||||||
|
old_sessions = manifest["sessions"][:-self.MAX_SESSIONS]
|
||||||
|
for session in old_sessions:
|
||||||
|
for file_info in session["files"]:
|
||||||
|
backup_file = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_file.exists():
|
||||||
|
backup_file.unlink()
|
||||||
|
manifest["sessions"] = manifest["sessions"][-self.MAX_SESSIONS:]
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
self._current_session = None
|
||||||
|
|
||||||
|
def restore_last(self) -> tuple[bool, list[str]]:
|
||||||
|
"""
|
||||||
|
Restore files from the last backup session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, list of restored files)
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
# Get last session
|
||||||
|
last_session = manifest["sessions"].pop()
|
||||||
|
restored_files = []
|
||||||
|
|
||||||
|
for file_info in last_session["files"]:
|
||||||
|
original_path = self.project_path / file_info["original"]
|
||||||
|
|
||||||
|
if file_info["existed"]:
|
||||||
|
# Restore from backup
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
# Ensure parent directory exists
|
||||||
|
original_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(backup_path, original_path)
|
||||||
|
backup_path.unlink() # Remove backup file
|
||||||
|
restored_files.append(file_info["original"])
|
||||||
|
else:
|
||||||
|
# File was newly created, delete it
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
restored_files.append(f"{file_info['original']} (deleted)")
|
||||||
|
|
||||||
|
self._save_manifest(manifest)
|
||||||
|
return True, restored_files
|
||||||
|
|
||||||
|
def list_backups(self) -> list[BackupInfo]:
|
||||||
|
"""
|
||||||
|
List all backup sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BackupInfo objects, newest first
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
files = [f["original"] for f in session["files"]]
|
||||||
|
backups.append(BackupInfo(
|
||||||
|
timestamp=session["timestamp"],
|
||||||
|
file_count=len(files),
|
||||||
|
files=files
|
||||||
|
))
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def get_file_backup_path(self, file_path: str) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Get the backup path for a file from the most recent session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Original file path (relative to project)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to backup file if found, None otherwise
|
||||||
|
"""
|
||||||
|
manifest = self._load_manifest()
|
||||||
|
|
||||||
|
if not manifest["sessions"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Search from newest to oldest
|
||||||
|
for session in reversed(manifest["sessions"]):
|
||||||
|
for file_info in session["files"]:
|
||||||
|
if file_info["original"] == file_path and file_info["existed"]:
|
||||||
|
backup_path = self.backup_dir / file_info["backup"]
|
||||||
|
if backup_path.exists():
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_all_backups(self) -> int:
|
||||||
|
"""
|
||||||
|
Clear all backups.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of backup files deleted
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
if self.backup_dir.exists():
|
||||||
|
for item in self.backup_dir.iterdir():
|
||||||
|
if item.name != ".gitignore":
|
||||||
|
if item.is_file():
|
||||||
|
item.unlink()
|
||||||
|
count += 1
|
||||||
|
elif item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# Reset manifest
|
||||||
|
self._save_manifest({"sessions": []})
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the backup manager."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Create a temporary project directory
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create some test files
|
||||||
|
test_file = Path(tmpdir) / "test.py"
|
||||||
|
test_file.write_text("print('hello')\n")
|
||||||
|
|
||||||
|
# Initialize backup manager
|
||||||
|
manager = BackupManager(tmpdir)
|
||||||
|
|
||||||
|
# Start a session and backup the file
|
||||||
|
manager.start_session()
|
||||||
|
backup_name = manager.backup("test.py")
|
||||||
|
print(f"Created backup: {backup_name}")
|
||||||
|
|
||||||
|
# Modify the file
|
||||||
|
test_file.write_text("print('modified')\n")
|
||||||
|
print(f"File content after modification: {test_file.read_text()}")
|
||||||
|
|
||||||
|
# End session
|
||||||
|
manager.end_session()
|
||||||
|
|
||||||
|
# List backups
|
||||||
|
backups = manager.list_backups()
|
||||||
|
print(f"Backup sessions: {len(backups)}")
|
||||||
|
for b in backups:
|
||||||
|
print(f" {b.timestamp}: {b.file_count} files")
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
success, restored = manager.restore_last()
|
||||||
|
print(f"Restore successful: {success}")
|
||||||
|
print(f"Restored files: {restored}")
|
||||||
|
print(f"File content after restore: {test_file.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""
|
||||||
|
DCTP Diff Generator - Generates diffs for preview display.
|
||||||
|
|
||||||
|
Compares old and new content and produces colored diff output
|
||||||
|
for the GUI preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DiffType(Enum):
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
ADDED = "added"
|
||||||
|
REMOVED = "removed"
|
||||||
|
CONTEXT = "context"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffLine:
|
||||||
|
"""Represents a single line in a diff."""
|
||||||
|
type: DiffType
|
||||||
|
line_number_old: Optional[int] # Line number in old file
|
||||||
|
line_number_new: Optional[int] # Line number in new file
|
||||||
|
content: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefix(self) -> str:
|
||||||
|
"""Get the diff prefix character."""
|
||||||
|
if self.type == DiffType.ADDED:
|
||||||
|
return "+"
|
||||||
|
elif self.type == DiffType.REMOVED:
|
||||||
|
return "-"
|
||||||
|
else:
|
||||||
|
return " "
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
old_num = str(self.line_number_old) if self.line_number_old else ""
|
||||||
|
new_num = str(self.line_number_new) if self.line_number_new else ""
|
||||||
|
return f"{old_num:>4} {new_num:>4} {self.prefix} {self.content}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiffBlock:
|
||||||
|
"""A block of related diff lines."""
|
||||||
|
start_old: int
|
||||||
|
end_old: int
|
||||||
|
start_new: int
|
||||||
|
end_new: int
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def header(self) -> str:
|
||||||
|
"""Generate a unified diff style header."""
|
||||||
|
return f"@@ -{self.start_old},{self.end_old - self.start_old + 1} +{self.start_new},{self.end_new - self.start_new + 1} @@"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileDiff:
|
||||||
|
"""Complete diff for a file."""
|
||||||
|
filename: str
|
||||||
|
old_content: list[str]
|
||||||
|
new_content: list[str]
|
||||||
|
blocks: list[DiffBlock]
|
||||||
|
lines: list[DiffLine]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_changes(self) -> bool:
|
||||||
|
return any(line.type in (DiffType.ADDED, DiffType.REMOVED) for line in self.lines)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def additions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.ADDED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deletions(self) -> int:
|
||||||
|
return sum(1 for line in self.lines if line.type == DiffType.REMOVED)
|
||||||
|
|
||||||
|
|
||||||
|
class DiffGenerator:
|
||||||
|
"""Generates diffs between old and new content."""
|
||||||
|
|
||||||
|
def __init__(self, context_lines: int = 3):
|
||||||
|
"""
|
||||||
|
Initialize diff generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context_lines: Number of context lines around changes
|
||||||
|
"""
|
||||||
|
self.context_lines = context_lines
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
filename: str = ""
|
||||||
|
) -> FileDiff:
|
||||||
|
"""
|
||||||
|
Generate a diff between old and new content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
filename: Optional filename for display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileDiff object with all diff information
|
||||||
|
"""
|
||||||
|
diff_lines: list[DiffLine] = []
|
||||||
|
|
||||||
|
# Use difflib to compute differences
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
old_line_num = 1
|
||||||
|
new_line_num = 1
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.UNCHANGED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
# Show removed lines first, then added
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.REMOVED,
|
||||||
|
line_number_old=old_line_num,
|
||||||
|
line_number_new=None,
|
||||||
|
content=old_lines[i1 + idx]
|
||||||
|
))
|
||||||
|
old_line_num += 1
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
diff_lines.append(DiffLine(
|
||||||
|
type=DiffType.ADDED,
|
||||||
|
line_number_old=None,
|
||||||
|
line_number_new=new_line_num,
|
||||||
|
content=new_lines[j1 + idx]
|
||||||
|
))
|
||||||
|
new_line_num += 1
|
||||||
|
|
||||||
|
# Generate blocks with context
|
||||||
|
blocks = self._generate_blocks(diff_lines)
|
||||||
|
|
||||||
|
return FileDiff(
|
||||||
|
filename=filename,
|
||||||
|
old_content=old_lines,
|
||||||
|
new_content=new_lines,
|
||||||
|
blocks=blocks,
|
||||||
|
lines=diff_lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_blocks(self, diff_lines: list[DiffLine]) -> list[DiffBlock]:
|
||||||
|
"""Generate diff blocks with context."""
|
||||||
|
if not diff_lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
blocks: list[DiffBlock] = []
|
||||||
|
current_block_lines: list[DiffLine] = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
|
||||||
|
for line in diff_lines:
|
||||||
|
is_change = line.type in (DiffType.ADDED, DiffType.REMOVED)
|
||||||
|
|
||||||
|
if is_change:
|
||||||
|
if not in_change:
|
||||||
|
# Starting a new change block, include context
|
||||||
|
in_change = True
|
||||||
|
unchanged_count = 0
|
||||||
|
current_block_lines.append(line)
|
||||||
|
|
||||||
|
else: # Unchanged line
|
||||||
|
if in_change:
|
||||||
|
unchanged_count += 1
|
||||||
|
if unchanged_count <= self.context_lines:
|
||||||
|
current_block_lines.append(line)
|
||||||
|
else:
|
||||||
|
# End current block and start fresh
|
||||||
|
if current_block_lines:
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
current_block_lines = []
|
||||||
|
in_change = False
|
||||||
|
unchanged_count = 0
|
||||||
|
else:
|
||||||
|
# Keep track of potential context lines
|
||||||
|
current_block_lines.append(line)
|
||||||
|
if len(current_block_lines) > self.context_lines:
|
||||||
|
current_block_lines.pop(0)
|
||||||
|
|
||||||
|
# Don't forget the last block
|
||||||
|
if current_block_lines and any(
|
||||||
|
l.type in (DiffType.ADDED, DiffType.REMOVED) for l in current_block_lines
|
||||||
|
):
|
||||||
|
blocks.append(self._create_block(current_block_lines))
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def _create_block(self, lines: list[DiffLine]) -> DiffBlock:
|
||||||
|
"""Create a DiffBlock from a list of lines."""
|
||||||
|
old_nums = [l.line_number_old for l in lines if l.line_number_old is not None]
|
||||||
|
new_nums = [l.line_number_new for l in lines if l.line_number_new is not None]
|
||||||
|
|
||||||
|
return DiffBlock(
|
||||||
|
start_old=min(old_nums) if old_nums else 0,
|
||||||
|
end_old=max(old_nums) if old_nums else 0,
|
||||||
|
start_new=min(new_nums) if new_nums else 0,
|
||||||
|
end_new=max(new_nums) if new_nums else 0,
|
||||||
|
lines=lines
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_unified_diff(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
old_filename: str = "a/file",
|
||||||
|
new_filename: str = "b/file"
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unified diff string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
old_filename: Label for old file
|
||||||
|
new_filename: Label for new file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unified diff as string
|
||||||
|
"""
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
old_lines,
|
||||||
|
new_lines,
|
||||||
|
fromfile=old_filename,
|
||||||
|
tofile=new_filename,
|
||||||
|
lineterm=""
|
||||||
|
)
|
||||||
|
return "\n".join(diff)
|
||||||
|
|
||||||
|
def generate_side_by_side(
|
||||||
|
self,
|
||||||
|
old_lines: list[str],
|
||||||
|
new_lines: list[str],
|
||||||
|
width: int = 80
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
|
"""
|
||||||
|
Generate a side-by-side diff representation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_lines: Original content lines
|
||||||
|
new_lines: New content lines
|
||||||
|
width: Width for each column
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of tuples (left_line, marker, right_line)
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
half_width = (width - 3) // 2
|
||||||
|
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
||||||
|
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((line, " ", line))
|
||||||
|
|
||||||
|
elif tag == 'replace':
|
||||||
|
max_len = max(i2 - i1, j2 - j1)
|
||||||
|
for idx in range(max_len):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width] if idx < i2 - i1 else ""
|
||||||
|
new_line = new_lines[j1 + idx][:half_width] if idx < j2 - j1 else ""
|
||||||
|
result.append((old_line, "|", new_line))
|
||||||
|
|
||||||
|
elif tag == 'delete':
|
||||||
|
for idx in range(i2 - i1):
|
||||||
|
old_line = old_lines[i1 + idx][:half_width]
|
||||||
|
result.append((old_line, "<", ""))
|
||||||
|
|
||||||
|
elif tag == 'insert':
|
||||||
|
for idx in range(j2 - j1):
|
||||||
|
new_line = new_lines[j1 + idx][:half_width]
|
||||||
|
result.append(("", ">", new_line))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_diff_for_display(diff: FileDiff, use_colors: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Format a FileDiff for terminal/GUI display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: The FileDiff to format
|
||||||
|
use_colors: Whether to use ANSI colors
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if diff.filename:
|
||||||
|
lines.append(f"--- {diff.filename}")
|
||||||
|
lines.append(f"+++ {diff.filename}")
|
||||||
|
|
||||||
|
for line in diff.lines:
|
||||||
|
if use_colors:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
prefix = "\033[32m+" # Green
|
||||||
|
suffix = "\033[0m"
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
prefix = "\033[31m-" # Red
|
||||||
|
suffix = "\033[0m"
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
suffix = ""
|
||||||
|
else:
|
||||||
|
prefix = line.prefix
|
||||||
|
suffix = ""
|
||||||
|
|
||||||
|
lines.append(f"{prefix} {line.content}{suffix}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the diff generator."""
|
||||||
|
old_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 1000:",
|
||||||
|
" rate = 0.25",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
new_content = [
|
||||||
|
"def calculate_tax(amount):",
|
||||||
|
" rate = 0.19",
|
||||||
|
" if amount > 10000:",
|
||||||
|
" rate = 0.22",
|
||||||
|
" elif amount > 1000:",
|
||||||
|
" rate = 0.19",
|
||||||
|
" return amount * rate",
|
||||||
|
]
|
||||||
|
|
||||||
|
generator = DiffGenerator()
|
||||||
|
diff = generator.generate(old_content, new_content, "calculator.py")
|
||||||
|
|
||||||
|
print(f"File: {diff.filename}")
|
||||||
|
print(f"Additions: {diff.additions}, Deletions: {diff.deletions}")
|
||||||
|
print()
|
||||||
|
print("Diff output:")
|
||||||
|
print(format_diff_for_display(diff))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
"""
|
||||||
|
DCTP Executor - Executes DCTP operations on files.
|
||||||
|
|
||||||
|
Handles CREATE, DELETE, INSERT_AFTER, REPLACE, and RENUMBER operations
|
||||||
|
with backup support and checksum validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, Operation, OperationType
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffGenerator, FileDiff
|
||||||
|
|
||||||
|
|
||||||
|
class ResultStatus(Enum):
|
||||||
|
SUCCESS = "success"
|
||||||
|
WARNING = "warning"
|
||||||
|
ERROR = "error"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionResult:
|
||||||
|
"""Result of executing a single operation."""
|
||||||
|
status: ResultStatus
|
||||||
|
operation: Operation
|
||||||
|
message: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
status_symbols = {
|
||||||
|
ResultStatus.SUCCESS: "✅",
|
||||||
|
ResultStatus.WARNING: "⚠️",
|
||||||
|
ResultStatus.ERROR: "❌",
|
||||||
|
ResultStatus.SKIPPED: "⏭️",
|
||||||
|
}
|
||||||
|
return f"{status_symbols[self.status]} {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreviewResult:
|
||||||
|
"""Result of previewing operations before execution."""
|
||||||
|
operation: Operation
|
||||||
|
description: str
|
||||||
|
diff: Optional[FileDiff] = None
|
||||||
|
warnings: list[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.warnings is None:
|
||||||
|
self.warnings = []
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPExecutor:
|
||||||
|
"""Executes DCTP operations on files."""
|
||||||
|
|
||||||
|
# Line number patterns (same as parser)
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'),
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'),
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'),
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_path: str,
|
||||||
|
backup_manager: Optional[BackupManager] = None,
|
||||||
|
auto_renumber: bool = True,
|
||||||
|
validate_checksums: bool = True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the executor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_path: Base project directory
|
||||||
|
backup_manager: Optional backup manager for undo support
|
||||||
|
auto_renumber: Automatically renumber after operations
|
||||||
|
validate_checksums: Validate checksums before operations
|
||||||
|
"""
|
||||||
|
self.project_path = Path(project_path)
|
||||||
|
self.backup_manager = backup_manager or BackupManager(project_path)
|
||||||
|
self.auto_renumber = auto_renumber
|
||||||
|
self.validate_checksums = validate_checksums
|
||||||
|
self.diff_generator = DiffGenerator()
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
|
||||||
|
def preview(self, operations: list[Operation]) -> list[PreviewResult]:
|
||||||
|
"""
|
||||||
|
Preview operations without executing them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to preview
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of preview results
|
||||||
|
"""
|
||||||
|
previews = []
|
||||||
|
|
||||||
|
for op in operations:
|
||||||
|
preview = self._preview_operation(op)
|
||||||
|
previews.append(preview)
|
||||||
|
|
||||||
|
return previews
|
||||||
|
|
||||||
|
def _preview_operation(self, op: Operation) -> PreviewResult:
|
||||||
|
"""Generate preview for a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
if file_path.exists():
|
||||||
|
warnings.append(f"File already exists and will be overwritten")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"CREATE {op.file} ({len(op.content)} lines)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
del new_lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.start_line > len(old_lines):
|
||||||
|
warnings.append(f"Line {op.start_line} exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
insert_idx = min(op.start_line, len(old_lines))
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
new_lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
if not file_path.exists():
|
||||||
|
warnings.append(f"File does not exist")
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} (file not found)",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
old_lines = self._read_file_lines(file_path)
|
||||||
|
if op.end_line > len(old_lines):
|
||||||
|
warnings.append(f"Line range exceeds file length ({len(old_lines)} lines)")
|
||||||
|
|
||||||
|
new_lines = old_lines.copy()
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = min(op.end_line, len(old_lines))
|
||||||
|
new_lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, new_lines, op.file)
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff,
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"RENUMBER {op.file}",
|
||||||
|
warnings=warnings
|
||||||
|
)
|
||||||
|
|
||||||
|
return PreviewResult(
|
||||||
|
operation=op,
|
||||||
|
description=f"UNKNOWN {op.type}",
|
||||||
|
warnings=["Unknown operation type"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
operations: list[Operation],
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> list[ExecutionResult]:
|
||||||
|
"""
|
||||||
|
Execute a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operations: List of operations to execute
|
||||||
|
skip_checksum_mismatch: Continue even if checksums don't match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Start backup session
|
||||||
|
self.backup_manager.start_session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for op in operations:
|
||||||
|
result = self._execute_operation(op, skip_checksum_mismatch)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Stop on error
|
||||||
|
if result.status == ResultStatus.ERROR:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# End backup session
|
||||||
|
self.backup_manager.end_session()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _execute_operation(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
skip_checksum_mismatch: bool = False
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a single operation."""
|
||||||
|
file_path = self.project_path / op.file
|
||||||
|
|
||||||
|
try:
|
||||||
|
if op.type == OperationType.NEW:
|
||||||
|
return self._execute_new(op, file_path)
|
||||||
|
elif op.type == OperationType.DELETE:
|
||||||
|
return self._execute_delete(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.INSERT_AFTER:
|
||||||
|
return self._execute_insert_after(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.REPLACE:
|
||||||
|
return self._execute_replace(op, file_path, skip_checksum_mismatch)
|
||||||
|
elif op.type == OperationType.RENUMBER:
|
||||||
|
return self._execute_renumber(op, file_path)
|
||||||
|
else:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Unknown operation type: {op.type}"
|
||||||
|
)
|
||||||
|
except PermissionError:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Permission denied: {file_path}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.ERROR,
|
||||||
|
operation=op,
|
||||||
|
message=f"Error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_new(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a NEW operation (create file)."""
|
||||||
|
# Backup if file exists
|
||||||
|
if file_path.exists():
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Create parent directories
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write content
|
||||||
|
content = "\n".join(op.content)
|
||||||
|
if op.content and not content.endswith("\n"):
|
||||||
|
content += "\n"
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"CREATE {op.file} ({len(op.content)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_delete(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a DELETE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and delete lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
del lines[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"DELETE {op.file} Z{op.start_line}-Z{op.end_line}",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_insert_after(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute an INSERT_AFTER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and insert lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.start_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line Z{op.start_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_idx = op.start_line
|
||||||
|
for i, line in enumerate(op.content):
|
||||||
|
lines.insert(insert_idx + i, line)
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"INSERT_AFTER {op.file} Z{op.start_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_replace(
|
||||||
|
self,
|
||||||
|
op: Operation,
|
||||||
|
file_path: Path,
|
||||||
|
skip_checksum_mismatch: bool
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""Execute a REPLACE operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate checksum if provided
|
||||||
|
if op.checksum and self.validate_checksums:
|
||||||
|
if not self._validate_checksum(file_path, op.checksum):
|
||||||
|
if not skip_checksum_mismatch:
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Checksum mismatch for {op.file} - file was modified externally"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup file
|
||||||
|
self.backup_manager.backup(str(op.file))
|
||||||
|
|
||||||
|
# Read file and replace lines
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
old_lines = lines.copy()
|
||||||
|
|
||||||
|
if op.end_line > len(lines):
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"Line range Z{op.start_line}-Z{op.end_line} exceeds file length ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
start_idx = op.start_line - 1
|
||||||
|
end_idx = op.end_line
|
||||||
|
lines[start_idx:end_idx] = op.content
|
||||||
|
|
||||||
|
# Write back
|
||||||
|
self._write_file_lines(file_path, lines)
|
||||||
|
|
||||||
|
diff = self.diff_generator.generate(old_lines, lines, op.file)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"REPLACE {op.file} Z{op.start_line}-Z{op.end_line} ({len(op.content)} lines)",
|
||||||
|
diff=diff
|
||||||
|
)
|
||||||
|
|
||||||
|
def _execute_renumber(self, op: Operation, file_path: Path) -> ExecutionResult:
|
||||||
|
"""Execute a RENUMBER operation."""
|
||||||
|
if not file_path.exists():
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.WARNING,
|
||||||
|
operation=op,
|
||||||
|
message=f"File not found: {op.file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No backup needed for renumber (just updates line numbers)
|
||||||
|
lines = self._read_file_lines(file_path)
|
||||||
|
renumbered_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
# Remove existing line number
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
# Add new line number
|
||||||
|
suffix = self.parser.get_line_number_suffix(op.file, i)
|
||||||
|
renumbered_lines.append(clean_line + suffix)
|
||||||
|
|
||||||
|
self._write_file_lines(file_path, renumbered_lines)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
status=ResultStatus.SUCCESS,
|
||||||
|
operation=op,
|
||||||
|
message=f"RENUMBER {op.file} ({len(lines)} lines)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_file_lines(self, file_path: Path) -> list[str]:
|
||||||
|
"""Read file and return lines without trailing newlines."""
|
||||||
|
content = file_path.read_text()
|
||||||
|
lines = content.split('\n')
|
||||||
|
# Remove trailing empty line if file ends with newline
|
||||||
|
if lines and lines[-1] == '':
|
||||||
|
lines = lines[:-1]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _write_file_lines(self, file_path: Path, lines: list[str]) -> None:
|
||||||
|
"""Write lines to file with trailing newline."""
|
||||||
|
content = '\n'.join(lines)
|
||||||
|
if lines and not content.endswith('\n'):
|
||||||
|
content += '\n'
|
||||||
|
file_path.write_text(content)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _validate_checksum(self, file_path: Path, expected: str) -> bool:
|
||||||
|
"""Validate file checksum."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
actual = hashlib.md5(content).hexdigest()[:8]
|
||||||
|
return actual.lower() == expected.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_checksum(file_path: Path) -> str:
|
||||||
|
"""Calculate checksum for a file."""
|
||||||
|
content = file_path.read_bytes()
|
||||||
|
return hashlib.md5(content).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the executor."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
test_input = """###FILE:calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Parsed operations:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
executor = DCTPExecutor(tmpdir)
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
print("\nPreviews:")
|
||||||
|
previews = executor.preview(result.operations)
|
||||||
|
for preview in previews:
|
||||||
|
print(f" {preview.description}")
|
||||||
|
if preview.warnings:
|
||||||
|
for w in preview.warnings:
|
||||||
|
print(f" ⚠️ {w}")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
print("\nExecution:")
|
||||||
|
exec_results = executor.execute(result.operations)
|
||||||
|
for r in exec_results:
|
||||||
|
print(f" {r}")
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
file_path = Path(tmpdir) / "calculator.py"
|
||||||
|
if file_path.exists():
|
||||||
|
print(f"\nFile content:\n{file_path.read_text()}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
DCTP GUI - Delta Code Transfer Protocol graphical user interface.
|
||||||
|
|
||||||
|
A CustomTkinter-based GUI for managing AI-generated code transfers
|
||||||
|
using delta operations for efficient updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from dctp_parser import DCTPParser, ParseResult, Operation, OperationType
|
||||||
|
from dctp_executor import DCTPExecutor, ExecutionResult, PreviewResult, ResultStatus
|
||||||
|
from dctp_backup import BackupManager
|
||||||
|
from dctp_diff import DiffType
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsDialog(ctk.CTkToplevel):
|
||||||
|
"""Settings dialog window."""
|
||||||
|
|
||||||
|
def __init__(self, parent, settings: dict):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.title("Einstellungen")
|
||||||
|
self.geometry("500x400")
|
||||||
|
self.resizable(False, False)
|
||||||
|
|
||||||
|
self.settings = settings.copy()
|
||||||
|
self.result = None
|
||||||
|
|
||||||
|
# Make modal
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Center on parent
|
||||||
|
self.update_idletasks()
|
||||||
|
x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2
|
||||||
|
y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2
|
||||||
|
self.geometry(f"+{x}+{y}")
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
# Main frame
|
||||||
|
main_frame = ctk.CTkFrame(self)
|
||||||
|
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||||||
|
|
||||||
|
# Project path
|
||||||
|
ctk.CTkLabel(main_frame, text="Standard-Projektpfad:").pack(anchor="w", pady=(0, 5))
|
||||||
|
path_frame = ctk.CTkFrame(main_frame)
|
||||||
|
path_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.path_entry = ctk.CTkEntry(path_frame, width=350)
|
||||||
|
self.path_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.path_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
path_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_path
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
ctk.CTkLabel(main_frame, text="Backup-Verzeichnis:").pack(anchor="w", pady=(0, 5))
|
||||||
|
backup_frame = ctk.CTkFrame(main_frame)
|
||||||
|
backup_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
self.backup_entry = ctk.CTkEntry(backup_frame, width=350)
|
||||||
|
self.backup_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.backup_entry.insert(0, self.settings.get("backup_dir", ".dctp_backups"))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
backup_frame,
|
||||||
|
text="...",
|
||||||
|
width=40,
|
||||||
|
command=self._browse_backup
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
# Options
|
||||||
|
options_frame = ctk.CTkFrame(main_frame)
|
||||||
|
options_frame.pack(fill="x", pady=15)
|
||||||
|
|
||||||
|
self.auto_renumber_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("auto_renumber", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Auto-Renumber nach Operationen",
|
||||||
|
variable=self.auto_renumber_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
self.validate_checksum_var = ctk.BooleanVar(
|
||||||
|
value=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
ctk.CTkCheckBox(
|
||||||
|
options_frame,
|
||||||
|
text="Checksum-Validierung aktiviert",
|
||||||
|
variable=self.validate_checksum_var
|
||||||
|
).pack(anchor="w", pady=5)
|
||||||
|
|
||||||
|
# Theme
|
||||||
|
ctk.CTkLabel(main_frame, text="Theme:").pack(anchor="w", pady=(15, 5))
|
||||||
|
self.theme_var = ctk.StringVar(value=self.settings.get("theme", "dark"))
|
||||||
|
theme_frame = ctk.CTkFrame(main_frame)
|
||||||
|
theme_frame.pack(fill="x", pady=(0, 15))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Dunkel",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="dark"
|
||||||
|
).pack(side="left", padx=(0, 20))
|
||||||
|
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
theme_frame,
|
||||||
|
text="Hell",
|
||||||
|
variable=self.theme_var,
|
||||||
|
value="light"
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(main_frame)
|
||||||
|
button_frame.pack(fill="x", pady=(20, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Abbrechen",
|
||||||
|
command=self._cancel
|
||||||
|
).pack(side="right", padx=(10, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Speichern",
|
||||||
|
command=self._save
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
def _browse_path(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=self.path_entry.get() or os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.path_entry.delete(0, "end")
|
||||||
|
self.path_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _browse_backup(self):
|
||||||
|
path = filedialog.askdirectory(
|
||||||
|
initialdir=os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.backup_entry.delete(0, "end")
|
||||||
|
self.backup_entry.insert(0, path)
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
self.result = {
|
||||||
|
"project_path": self.path_entry.get(),
|
||||||
|
"backup_dir": self.backup_entry.get(),
|
||||||
|
"auto_renumber": self.auto_renumber_var.get(),
|
||||||
|
"validate_checksum": self.validate_checksum_var.get(),
|
||||||
|
"theme": self.theme_var.get()
|
||||||
|
}
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _cancel(self):
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPApp(ctk.CTk):
|
||||||
|
"""Main DCTP application window."""
|
||||||
|
|
||||||
|
SETTINGS_FILE = ".dctp_settings.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.title("DCTP - Delta Code Transfer")
|
||||||
|
self.geometry("1200x900")
|
||||||
|
self.minsize(800, 600)
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
self.parser = DCTPParser()
|
||||||
|
self.executor: Optional[DCTPExecutor] = None
|
||||||
|
self.backup_manager: Optional[BackupManager] = None
|
||||||
|
self.current_operations: list[Operation] = []
|
||||||
|
self.current_previews: list[PreviewResult] = []
|
||||||
|
|
||||||
|
# Load settings
|
||||||
|
self.settings = self._load_settings()
|
||||||
|
ctk.set_appearance_mode(self.settings.get("theme", "dark"))
|
||||||
|
|
||||||
|
# Create UI
|
||||||
|
self._create_widgets()
|
||||||
|
|
||||||
|
# Initialize project if path is set
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Bereit")
|
||||||
|
|
||||||
|
def _load_settings(self) -> dict:
|
||||||
|
"""Load settings from file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
if settings_path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(settings_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"project_path": "",
|
||||||
|
"backup_dir": ".dctp_backups",
|
||||||
|
"auto_renumber": True,
|
||||||
|
"validate_checksum": True,
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _save_settings(self):
|
||||||
|
"""Save settings to file."""
|
||||||
|
settings_path = Path.home() / self.SETTINGS_FILE
|
||||||
|
settings_path.write_text(json.dumps(self.settings, indent=2))
|
||||||
|
|
||||||
|
def _create_widgets(self):
|
||||||
|
"""Create all UI widgets."""
|
||||||
|
# Top bar - project selection
|
||||||
|
top_frame = ctk.CTkFrame(self)
|
||||||
|
top_frame.pack(fill="x", padx=10, pady=10)
|
||||||
|
|
||||||
|
ctk.CTkLabel(top_frame, text="Projekt:").pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.project_entry = ctk.CTkEntry(top_frame, width=400)
|
||||||
|
self.project_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
|
||||||
|
self.project_entry.insert(0, self.settings.get("project_path", ""))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Waehlen",
|
||||||
|
width=100,
|
||||||
|
command=self._browse_project
|
||||||
|
).pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
top_frame,
|
||||||
|
text="Einstellungen",
|
||||||
|
width=100,
|
||||||
|
command=self._open_settings
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Main content area with paned layout
|
||||||
|
content_frame = ctk.CTkFrame(self)
|
||||||
|
content_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||||||
|
|
||||||
|
# Left side - Input and preview
|
||||||
|
left_frame = ctk.CTkFrame(content_frame)
|
||||||
|
left_frame.pack(side="left", fill="both", expand=True, padx=(0, 5))
|
||||||
|
|
||||||
|
# Input area
|
||||||
|
input_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
input_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
input_label_frame,
|
||||||
|
text="Input (KI-Output hier einfuegen)",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.input_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=250,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=12)
|
||||||
|
)
|
||||||
|
self.input_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
button_frame = ctk.CTkFrame(left_frame)
|
||||||
|
button_frame.pack(fill="x", padx=5, pady=(0, 10))
|
||||||
|
|
||||||
|
self.analyze_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Analysieren",
|
||||||
|
command=self._analyze,
|
||||||
|
width=120
|
||||||
|
)
|
||||||
|
self.analyze_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.execute_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Ausfuehren",
|
||||||
|
command=self._execute,
|
||||||
|
width=120,
|
||||||
|
state="disabled"
|
||||||
|
)
|
||||||
|
self.execute_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
self.undo_btn = ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Undo",
|
||||||
|
command=self._undo,
|
||||||
|
width=80
|
||||||
|
)
|
||||||
|
self.undo_btn.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
button_frame,
|
||||||
|
text="Clear",
|
||||||
|
command=self._clear,
|
||||||
|
width=80
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Preview operations
|
||||||
|
preview_label_frame = ctk.CTkFrame(left_frame)
|
||||||
|
preview_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
preview_label_frame,
|
||||||
|
text="Vorschau Operationen",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.preview_text = ctk.CTkTextbox(
|
||||||
|
left_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.preview_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Right side - Diff and file tree
|
||||||
|
right_frame = ctk.CTkFrame(content_frame)
|
||||||
|
right_frame.pack(side="right", fill="both", expand=True, padx=(5, 0))
|
||||||
|
|
||||||
|
# Diff view
|
||||||
|
diff_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
diff_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
diff_label_frame,
|
||||||
|
text="Diff-Ansicht",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.diff_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=300,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.diff_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# File tree
|
||||||
|
tree_label_frame = ctk.CTkFrame(right_frame)
|
||||||
|
tree_label_frame.pack(fill="x", pady=(5, 5), padx=5)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Projektdateien",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
tree_label_frame,
|
||||||
|
text="Aktualisieren",
|
||||||
|
width=80,
|
||||||
|
command=self._refresh_file_tree
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
self.tree_text = ctk.CTkTextbox(
|
||||||
|
right_frame,
|
||||||
|
height=150,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=11)
|
||||||
|
)
|
||||||
|
self.tree_text.pack(fill="both", expand=True, padx=5, pady=(0, 10))
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Bottom - Log
|
||||||
|
log_label_frame = ctk.CTkFrame(self)
|
||||||
|
log_label_frame.pack(fill="x", padx=10, pady=(0, 5))
|
||||||
|
ctk.CTkLabel(
|
||||||
|
log_label_frame,
|
||||||
|
text="Log",
|
||||||
|
font=ctk.CTkFont(weight="bold")
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
self.log_text = ctk.CTkTextbox(
|
||||||
|
self,
|
||||||
|
height=120,
|
||||||
|
font=ctk.CTkFont(family="Consolas", size=10)
|
||||||
|
)
|
||||||
|
self.log_text.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _log(self, message: str, level: str = "info"):
|
||||||
|
"""Add a message to the log."""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
prefix = ""
|
||||||
|
if level == "error":
|
||||||
|
prefix = "ERROR "
|
||||||
|
elif level == "warning":
|
||||||
|
prefix = "WARN "
|
||||||
|
|
||||||
|
self.log_text.configure(state="normal")
|
||||||
|
self.log_text.insert("end", f"{timestamp} {prefix}{message}\n")
|
||||||
|
self.log_text.see("end")
|
||||||
|
self.log_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _init_project(self, path: str):
|
||||||
|
"""Initialize project with given path."""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
self._log(f"Verzeichnis existiert nicht: {path}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.backup_manager = BackupManager(path)
|
||||||
|
self.executor = DCTPExecutor(
|
||||||
|
path,
|
||||||
|
self.backup_manager,
|
||||||
|
auto_renumber=self.settings.get("auto_renumber", True),
|
||||||
|
validate_checksums=self.settings.get("validate_checksum", True)
|
||||||
|
)
|
||||||
|
self.settings["project_path"] = path
|
||||||
|
self._save_settings()
|
||||||
|
self._log(f"Projekt geladen: {path}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _browse_project(self):
|
||||||
|
"""Open directory browser for project selection."""
|
||||||
|
initial_dir = self.project_entry.get() or os.path.expanduser("~")
|
||||||
|
path = filedialog.askdirectory(initialdir=initial_dir)
|
||||||
|
if path:
|
||||||
|
self.project_entry.delete(0, "end")
|
||||||
|
self.project_entry.insert(0, path)
|
||||||
|
self._init_project(path)
|
||||||
|
|
||||||
|
def _open_settings(self):
|
||||||
|
"""Open settings dialog."""
|
||||||
|
dialog = SettingsDialog(self, self.settings)
|
||||||
|
self.wait_window(dialog)
|
||||||
|
|
||||||
|
if dialog.result:
|
||||||
|
old_theme = self.settings.get("theme")
|
||||||
|
self.settings.update(dialog.result)
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
# Apply theme change
|
||||||
|
if dialog.result.get("theme") != old_theme:
|
||||||
|
ctk.set_appearance_mode(dialog.result["theme"])
|
||||||
|
|
||||||
|
# Reinitialize project with new settings
|
||||||
|
if self.settings.get("project_path"):
|
||||||
|
self._init_project(self.settings["project_path"])
|
||||||
|
|
||||||
|
self._log("Einstellungen gespeichert")
|
||||||
|
|
||||||
|
def _analyze(self):
|
||||||
|
"""Analyze input and show preview."""
|
||||||
|
# Ensure project is initialized
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path:
|
||||||
|
messagebox.showerror("Fehler", "Bitte waehle ein Projektverzeichnis")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor or self.settings.get("project_path") != project_path:
|
||||||
|
if not self._init_project(project_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get input
|
||||||
|
input_text = self.input_text.get("1.0", "end-1c")
|
||||||
|
if not input_text.strip():
|
||||||
|
self._log("Kein Input vorhanden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse
|
||||||
|
self._log("Analysiere...")
|
||||||
|
result = self.parser.parse(input_text)
|
||||||
|
|
||||||
|
# Handle errors
|
||||||
|
if result.has_errors:
|
||||||
|
self._log(f"{len(result.errors)} Parse-Fehler gefunden", "error")
|
||||||
|
for error in result.errors:
|
||||||
|
self._log(f" Zeile {error.line_number}: {error.message}", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not result.operations:
|
||||||
|
self._log("Keine Operationen gefunden", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_operations = result.operations
|
||||||
|
self._log(f"{len(result.operations)} Operationen gefunden")
|
||||||
|
|
||||||
|
# Generate previews
|
||||||
|
self.current_previews = self.executor.preview(result.operations)
|
||||||
|
|
||||||
|
# Display previews
|
||||||
|
self._display_previews()
|
||||||
|
|
||||||
|
# Enable execute button
|
||||||
|
self.execute_btn.configure(state="normal")
|
||||||
|
|
||||||
|
def _display_previews(self):
|
||||||
|
"""Display operation previews."""
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
for preview in self.current_previews:
|
||||||
|
# Add to preview list
|
||||||
|
self.preview_text.insert("end", f"{preview.description}\n")
|
||||||
|
for warning in preview.warnings:
|
||||||
|
self.preview_text.insert("end", f" WARNING {warning}\n")
|
||||||
|
|
||||||
|
# Add diff if available
|
||||||
|
if preview.diff and preview.diff.has_changes:
|
||||||
|
self.diff_text.insert("end", f"--- {preview.diff.filename} ---\n")
|
||||||
|
for line in preview.diff.lines:
|
||||||
|
if line.type == DiffType.ADDED:
|
||||||
|
self.diff_text.insert("end", f"+ {line.content}\n")
|
||||||
|
elif line.type == DiffType.REMOVED:
|
||||||
|
self.diff_text.insert("end", f"- {line.content}\n")
|
||||||
|
elif line.type == DiffType.UNCHANGED:
|
||||||
|
self.diff_text.insert("end", f" {line.content}\n")
|
||||||
|
self.diff_text.insert("end", "\n")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _execute(self):
|
||||||
|
"""Execute the analyzed operations."""
|
||||||
|
if not self.current_operations:
|
||||||
|
self._log("Keine Operationen zum Ausfuehren", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.executor:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm
|
||||||
|
count = len(self.current_operations)
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Bestaetigen",
|
||||||
|
f"{count} Operationen ausfuehren?"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log(f"Fuehre {count} Operationen aus...")
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
results = self.executor.execute(self.current_operations)
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
success_count = 0
|
||||||
|
for result in results:
|
||||||
|
if result.status == ResultStatus.SUCCESS:
|
||||||
|
self._log(f"OK {result.message}")
|
||||||
|
success_count += 1
|
||||||
|
elif result.status == ResultStatus.WARNING:
|
||||||
|
self._log(f"WARN {result.message}", "warning")
|
||||||
|
elif result.status == ResultStatus.ERROR:
|
||||||
|
self._log(f"ERROR {result.message}", "error")
|
||||||
|
|
||||||
|
self._log(f"Abgeschlossen: {success_count}/{count} erfolgreich")
|
||||||
|
|
||||||
|
# Clear current operations
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
# Refresh file tree
|
||||||
|
self._refresh_file_tree()
|
||||||
|
|
||||||
|
def _undo(self):
|
||||||
|
"""Undo last operation."""
|
||||||
|
if not self.backup_manager:
|
||||||
|
self._log("Kein Projekt initialisiert", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
success, restored = self.backup_manager.restore_last()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self._log(f"Undo erfolgreich: {len(restored)} Dateien wiederhergestellt")
|
||||||
|
for f in restored:
|
||||||
|
self._log(f" -> {f}")
|
||||||
|
self._refresh_file_tree()
|
||||||
|
else:
|
||||||
|
self._log("Kein Backup zum Wiederherstellen", "warning")
|
||||||
|
|
||||||
|
def _clear(self):
|
||||||
|
"""Clear input and preview areas."""
|
||||||
|
self.input_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
self.preview_text.configure(state="normal")
|
||||||
|
self.preview_text.delete("1.0", "end")
|
||||||
|
self.preview_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.diff_text.configure(state="normal")
|
||||||
|
self.diff_text.delete("1.0", "end")
|
||||||
|
self.diff_text.configure(state="disabled")
|
||||||
|
|
||||||
|
self.current_operations = []
|
||||||
|
self.current_previews = []
|
||||||
|
self.execute_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
self._log("Eingabe geloescht")
|
||||||
|
|
||||||
|
def _refresh_file_tree(self):
|
||||||
|
"""Refresh the file tree display."""
|
||||||
|
self.tree_text.configure(state="normal")
|
||||||
|
self.tree_text.delete("1.0", "end")
|
||||||
|
|
||||||
|
project_path = self.project_entry.get()
|
||||||
|
if not project_path or not os.path.isdir(project_path):
|
||||||
|
self.tree_text.insert("end", "(Kein Projekt geladen)")
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build simple tree
|
||||||
|
try:
|
||||||
|
self._add_tree_items(Path(project_path), 0)
|
||||||
|
except Exception as e:
|
||||||
|
self.tree_text.insert("end", f"Fehler: {e}")
|
||||||
|
|
||||||
|
self.tree_text.configure(state="disabled")
|
||||||
|
|
||||||
|
def _add_tree_items(self, path: Path, level: int, max_items: int = 100):
|
||||||
|
"""Recursively add items to tree display."""
|
||||||
|
if level > 5: # Limit depth
|
||||||
|
return
|
||||||
|
|
||||||
|
indent = " " * level
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if count >= max_items:
|
||||||
|
self.tree_text.insert("end", f"{indent} ... (mehr Dateien)\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip hidden files and backup directory
|
||||||
|
if item.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.is_dir():
|
||||||
|
self.tree_text.insert("end", f"{indent}DIR {item.name}/\n")
|
||||||
|
self._add_tree_items(item, level + 1, max_items=20)
|
||||||
|
else:
|
||||||
|
self.tree_text.insert("end", f"{indent}FILE {item.name}\n")
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
self.tree_text.insert("end", f"{indent} (Zugriff verweigert)\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
app = DCTPApp()
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
DCTP Parser - Parses DCTP control commands and code blocks.
|
||||||
|
|
||||||
|
Handles line-numbered code with language-specific comment formats:
|
||||||
|
- Python/Shell: #Z1
|
||||||
|
- JavaScript/Java/C/C++: //Z1
|
||||||
|
- HTML: <!--Z1-->
|
||||||
|
- CSS: /*Z1*/
|
||||||
|
- SQL: --Z1
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OperationType(Enum):
|
||||||
|
NEW = "NEW"
|
||||||
|
DELETE = "DELETE"
|
||||||
|
INSERT_AFTER = "INSERT_AFTER"
|
||||||
|
REPLACE = "REPLACE"
|
||||||
|
RENUMBER = "RENUMBER"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operation:
|
||||||
|
"""Represents a single DCTP operation."""
|
||||||
|
type: OperationType
|
||||||
|
file: str
|
||||||
|
start_line: Optional[int] = None
|
||||||
|
end_line: Optional[int] = None
|
||||||
|
content: list[str] = field(default_factory=list)
|
||||||
|
checksum: Optional[str] = None
|
||||||
|
raw_content: list[str] = field(default_factory=list) # Content with line numbers
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.type == OperationType.NEW:
|
||||||
|
return f"CREATE {self.file} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.DELETE:
|
||||||
|
return f"DELETE {self.file} Z{self.start_line}-Z{self.end_line}"
|
||||||
|
elif self.type == OperationType.INSERT_AFTER:
|
||||||
|
return f"INSERT_AFTER {self.file} Z{self.start_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.REPLACE:
|
||||||
|
return f"REPLACE {self.file} Z{self.start_line}-Z{self.end_line} ({len(self.content)} lines)"
|
||||||
|
elif self.type == OperationType.RENUMBER:
|
||||||
|
return f"RENUMBER {self.file}"
|
||||||
|
return f"{self.type.value} {self.file}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseError:
|
||||||
|
"""Represents a parsing error."""
|
||||||
|
line_number: int
|
||||||
|
line_content: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParseResult:
|
||||||
|
"""Result of parsing DCTP input."""
|
||||||
|
operations: list[Operation]
|
||||||
|
errors: list[ParseError]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
return len(self.errors) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class DCTPParser:
|
||||||
|
"""Parser for DCTP (Delta Code Transfer Protocol) format."""
|
||||||
|
|
||||||
|
# Regex patterns for line number markers in different languages
|
||||||
|
LINE_NUMBER_PATTERNS = [
|
||||||
|
re.compile(r'\s*#Z(\d+)\s*$'), # Python, Shell
|
||||||
|
re.compile(r'\s*//Z(\d+)\s*$'), # JavaScript, Java, C, C++
|
||||||
|
re.compile(r'\s*<!--Z(\d+)-->\s*$'), # HTML
|
||||||
|
re.compile(r'\s*/\*Z(\d+)\*/\s*$'), # CSS
|
||||||
|
re.compile(r'\s*--Z(\d+)\s*$'), # SQL
|
||||||
|
]
|
||||||
|
|
||||||
|
# Control command patterns
|
||||||
|
FILE_PATTERN = re.compile(r'^###FILE:(.+)$')
|
||||||
|
NEW_PATTERN = re.compile(r'^###NEW\s*$')
|
||||||
|
DELETE_PATTERN = re.compile(r'^###DELETE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
INSERT_AFTER_PATTERN = re.compile(r'^###INSERT_AFTER:Z(\d+)\s*$')
|
||||||
|
REPLACE_PATTERN = re.compile(r'^###REPLACE:Z(\d+)(?:-Z(\d+))?\s*$')
|
||||||
|
END_PATTERN = re.compile(r'^###END\s*$')
|
||||||
|
RENUMBER_PATTERN = re.compile(r'^###RENUMBER\s*$')
|
||||||
|
CHECKSUM_PATTERN = re.compile(r'^###CHECKSUM:([a-fA-F0-9]+)\s*$')
|
||||||
|
|
||||||
|
def parse(self, text: str) -> ParseResult:
|
||||||
|
"""
|
||||||
|
Parse DCTP formatted text into a list of operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The DCTP formatted input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ParseResult containing operations and any errors
|
||||||
|
"""
|
||||||
|
operations: list[Operation] = []
|
||||||
|
errors: list[ParseError] = []
|
||||||
|
|
||||||
|
current_file: Optional[str] = None
|
||||||
|
current_op: Optional[Operation] = None
|
||||||
|
buffer: list[str] = []
|
||||||
|
raw_buffer: list[str] = []
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
for line_num, line in enumerate(lines, 1):
|
||||||
|
# Skip empty lines outside of content blocks
|
||||||
|
if not line.strip() and current_op is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for FILE command
|
||||||
|
file_match = self.FILE_PATTERN.match(line)
|
||||||
|
if file_match:
|
||||||
|
current_file = file_match.group(1).strip()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for NEW command
|
||||||
|
if self.NEW_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###NEW without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(type=OperationType.NEW, file=current_file)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for DELETE command
|
||||||
|
delete_match = self.DELETE_PATTERN.match(line)
|
||||||
|
if delete_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###DELETE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(delete_match.group(1))
|
||||||
|
end = int(delete_match.group(2)) if delete_match.group(2) else start
|
||||||
|
operations.append(Operation(
|
||||||
|
type=OperationType.DELETE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for INSERT_AFTER command
|
||||||
|
insert_match = self.INSERT_AFTER_PATTERN.match(line)
|
||||||
|
if insert_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###INSERT_AFTER without ###FILE"))
|
||||||
|
continue
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.INSERT_AFTER,
|
||||||
|
file=current_file,
|
||||||
|
start_line=int(insert_match.group(1))
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for REPLACE command
|
||||||
|
replace_match = self.REPLACE_PATTERN.match(line)
|
||||||
|
if replace_match:
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###REPLACE without ###FILE"))
|
||||||
|
continue
|
||||||
|
start = int(replace_match.group(1))
|
||||||
|
end = int(replace_match.group(2)) if replace_match.group(2) else start
|
||||||
|
current_op = Operation(
|
||||||
|
type=OperationType.REPLACE,
|
||||||
|
file=current_file,
|
||||||
|
start_line=start,
|
||||||
|
end_line=end
|
||||||
|
)
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for END command
|
||||||
|
if self.END_PATTERN.match(line):
|
||||||
|
if current_op:
|
||||||
|
current_op.content = buffer.copy()
|
||||||
|
current_op.raw_content = raw_buffer.copy()
|
||||||
|
operations.append(current_op)
|
||||||
|
current_op = None
|
||||||
|
buffer = []
|
||||||
|
raw_buffer = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for RENUMBER command
|
||||||
|
if self.RENUMBER_PATTERN.match(line):
|
||||||
|
if current_file is None:
|
||||||
|
errors.append(ParseError(line_num, line, "###RENUMBER without ###FILE"))
|
||||||
|
continue
|
||||||
|
operations.append(Operation(type=OperationType.RENUMBER, file=current_file))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for CHECKSUM command
|
||||||
|
checksum_match = self.CHECKSUM_PATTERN.match(line)
|
||||||
|
if checksum_match:
|
||||||
|
if current_op:
|
||||||
|
current_op.checksum = checksum_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Regular code line - add to buffer if we're in an operation
|
||||||
|
if current_op is not None:
|
||||||
|
raw_buffer.append(line)
|
||||||
|
clean_line = self._remove_line_number(line)
|
||||||
|
buffer.append(clean_line)
|
||||||
|
|
||||||
|
# Handle unclosed operation
|
||||||
|
if current_op is not None:
|
||||||
|
errors.append(ParseError(
|
||||||
|
len(lines),
|
||||||
|
"",
|
||||||
|
f"Unclosed operation: {current_op.type.value} for {current_op.file}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return ParseResult(operations=operations, errors=errors)
|
||||||
|
|
||||||
|
def _remove_line_number(self, line: str) -> str:
|
||||||
|
"""Remove line number marker from end of line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return line[:match.start()]
|
||||||
|
return line
|
||||||
|
|
||||||
|
def extract_line_number(self, line: str) -> Optional[int]:
|
||||||
|
"""Extract line number from a code line."""
|
||||||
|
for pattern in self.LINE_NUMBER_PATTERNS:
|
||||||
|
match = pattern.search(line)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_line_number_suffix(filename: str, line_num: int) -> str:
|
||||||
|
"""Get the appropriate line number suffix for a file type."""
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
if ext in ('py', 'sh', 'bash', 'zsh', 'yaml', 'yml', 'toml', 'ini', 'conf', 'rb', 'pl'):
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
elif ext in ('js', 'ts', 'jsx', 'tsx', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'scala'):
|
||||||
|
return f" //Z{line_num}"
|
||||||
|
elif ext in ('html', 'htm', 'xml', 'svg'):
|
||||||
|
return f" <!--Z{line_num}-->"
|
||||||
|
elif ext in ('css', 'scss', 'sass', 'less'):
|
||||||
|
return f" /*Z{line_num}*/"
|
||||||
|
elif ext in ('sql',):
|
||||||
|
return f" --Z{line_num}"
|
||||||
|
else:
|
||||||
|
# Default to Python style
|
||||||
|
return f" #Z{line_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Test the parser with example input."""
|
||||||
|
test_input = """###FILE:src/calculator.py
|
||||||
|
###NEW
|
||||||
|
def add(a, b): #Z1
|
||||||
|
return a + b #Z2
|
||||||
|
#Z3
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
return a * b #Z5
|
||||||
|
###END
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###REPLACE:Z4-Z5
|
||||||
|
def multiply(a, b): #Z4
|
||||||
|
\"\"\"Multipliziert zwei Zahlen.\"\"\" #Z5
|
||||||
|
return a * b #Z6
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###INSERT_AFTER:Z2
|
||||||
|
#Z3
|
||||||
|
def subtract(a, b): #Z4
|
||||||
|
return a - b #Z5
|
||||||
|
###END
|
||||||
|
###RENUMBER
|
||||||
|
|
||||||
|
###FILE:src/calculator.py
|
||||||
|
###DELETE:Z10-Z15
|
||||||
|
###RENUMBER
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = DCTPParser()
|
||||||
|
result = parser.parse(test_input)
|
||||||
|
|
||||||
|
print("Operations found:")
|
||||||
|
for op in result.operations:
|
||||||
|
print(f" {op}")
|
||||||
|
|
||||||
|
if result.has_errors:
|
||||||
|
print("\nErrors:")
|
||||||
|
for error in result.errors:
|
||||||
|
print(f" Line {error.line_number}: {error.message}")
|
||||||
|
print(f" {error.line_content}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
customtkinter>=5.2.0
|
||||||
Reference in New Issue
Block a user