Compare commits

..

8 Commits

Author SHA1 Message Date
Claude b953908f58 Add HealthBridge iOS app for intelligent health data synchronization
Complete implementation of a SwiftUI iOS app that serves as a "Single Source
of Truth" for health data. The app reads from all Apple Health sources,
detects conflicts between devices, merges data using configurable strategies,
and writes cleaned data back.

Features:
- Phase 1: HealthKit integration with automatic source discovery
- Phase 2: DataReader with conflict detection (time-window based)
- Phase 3: RuleEngine with 8 merge strategies (exclusive, priority, higher wins, etc.)
- Phase 4: MergeEngine for conflict resolution + DataWriter for HealthKit writes
- Phase 5: SwiftUI UI for dashboard, conflicts, rules, and sources management
- Phase 6: Background sync with configurable intervals and push notifications
- Phase 7: Complete rule editor and polished UI components

Supported data types:
- Steps, Heart Rate, Blood Pressure, SpO2, Sleep
- Distance, Floors Climbed, Active Energy, HRV, Respiratory Rate

Architecture: SourceManager -> DataReader -> RuleEngine -> MergeEngine -> DataWriter
2025-12-25 16:59:48 +00:00
admin 0ffb1c771e Merge pull request #12 from metacube2/claude/dctp-delta-transfer-swz4W
Build Delta Code Transfer Protocol tool
2025-12-25 14:51:10 +01:00
Claude 8858a08a32 Add DCTP (Delta Code Transfer Protocol) tool for efficient AI code transfers
DCTP enables efficient transfer of AI-generated code using delta operations
instead of sending complete files for each modification. Features include:

- Parser for DCTP control commands (NEW, DELETE, INSERT_AFTER, REPLACE, RENUMBER)
- Line-numbered code with language-specific comment formats
- Backup/Undo system with session management
- Diff generation for preview functionality
- CustomTkinter GUI with project management, preview, and diff views
2025-12-25 12:59:07 +00:00
admin faa36d0e5e Merge pull request #11 from metacube2/claude/family-albums-portal-jhT0O
Build family photo album portal backend
2025-12-25 11:52:32 +01:00
Claude 25766959f1 Add FamilyAlbums family photo portal with Nextcloud integration
A PHP-based family photo album portal featuring:
- Public gallery with year/month filtering and search
- Mobile-responsive design with Tailwind CSS
- Comment system for family members
- Admin interface for album management
- Flat-file JSON database (no MySQL needed)
- CSRF protection and XSS prevention
- Rate limiting and honeypot spam protection
2025-12-25 09:57:51 +00:00
admin 5f949121bf Merge pull request #10 from metacube2/claude/psytrance-visualizer-swift-bFGOw
Build Psytrance Visualizer with Swift and Metal
2025-12-22 22:38:56 +01:00
Claude a22c238dc4 Add Psytrance Visualizer macOS app with Metal rendering
A complete audio-reactive visualizer for psytrance music featuring:

Audio Analysis (DSPEngine):
- FFT spectrum analysis via Accelerate/vDSP
- 64-band Mel spectrogram
- Sub-bass energy extraction (<100Hz)
- Automatic sidechain pump detection
- Harmonic-to-Noise ratio (HNR) calculation
- Peak/transient detection

8 Visualization Modes (Metal Shaders):
1. FFT Classic - Frequency spectrum bars with glow
2. Mel Spectrogram - Waterfall display
3. Sub-Bass - Pulsating rings
4. Sidechain Pump - Breathing zoom effect
5. Harmonic/Noise - Geometric vs chaotic particles
6. Mandelbrot - Audio-reactive fractal zoom
7. Tunnel Warp - Infinite tunnel with distortion
8. DMT Geometry - Sacred geometry patterns

Features:
- Selectable audio input device (BlackHole support)
- Configurable buffer size (512/1024)
- Reactivity slider for visual intensity
- Auto-hiding control panel
- Fullscreen support with keyboard shortcuts (1-8, F, ESC)
- Persistent settings via UserDefaults
- Psytrance-inspired neon/UV color palette
2025-12-22 21:36:45 +00:00
admin b607a9cd8a Merge pull request #9 from metacube2/claude/suitcase-arcade-game-6y4do
Add RollkofferSimulator iOS SpriteKit arcade game
2025-12-20 18:20:36 +01:00
71 changed files with 15292 additions and 0 deletions
+91
View File
@@ -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
}
}
+315
View File
@@ -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
}
}
+269
View File
@@ -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)
}
}
+183
View File
@@ -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
}
+140
View File
@@ -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>
+74
View File
@@ -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>
+488
View File
@@ -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)
}
}
}
+395
View File
@@ -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")
}
}
+287
View File
@@ -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"
}
}
+571
View File
@@ -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!"
}
}
}
}
+409
View File
@@ -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 [:]
}
}
}
+301
View File
@@ -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"
}
}
}
+153
View File
@@ -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()
}
+348
View File
@@ -0,0 +1,348 @@
import SwiftUI
struct ConflictsView: View {
@EnvironmentObject var syncCoordinator: SyncCoordinator
@State private var selectedConflict: Conflict?
@State private var showingDetail = false
var body: some View {
NavigationStack {
Group {
if syncCoordinator.pendingConflicts.isEmpty {
emptyState
} else {
conflictsList
}
}
.navigationTitle("Konflikte")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle automatisch lösen") {
Task { await resolveAllAuto() }
}
Button("Alle ignorieren", role: .destructive) {
ignoreAll()
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(syncCoordinator.pendingConflicts.isEmpty)
}
}
.sheet(item: $selectedConflict) { conflict in
ConflictDetailView(conflict: conflict) { selectedReadingId in
Task {
await resolveConflict(conflict, selectedReadingId: selectedReadingId)
}
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Keine Konflikte")
.font(.title2)
.fontWeight(.semibold)
Text("Alle Ihre Gesundheitsdaten sind synchronisiert")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
// MARK: - Conflicts List
private var conflictsList: some View {
List {
ForEach(groupedConflicts.keys.sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
Section(dataType.displayName) {
ForEach(groupedConflicts[dataType] ?? []) { conflict in
ConflictRow(conflict: conflict)
.contentShape(Rectangle())
.onTapGesture {
selectedConflict = conflict
}
}
}
}
}
.listStyle(.insetGrouped)
}
private var groupedConflicts: [HealthDataType: [Conflict]] {
Dictionary(grouping: syncCoordinator.pendingConflicts, by: { $0.dataType })
}
// MARK: - Actions
private func resolveConflict(_ conflict: Conflict, selectedReadingId: UUID) async {
do {
try await syncCoordinator.resolveConflict(conflict, selectedReadingId: selectedReadingId)
selectedConflict = nil
} catch {
print("Failed to resolve conflict: \(error)")
}
}
private func resolveAllAuto() async {
for conflict in syncCoordinator.pendingConflicts {
if let primaryReading = conflict.primarySourceReading {
try? await syncCoordinator.resolveConflict(conflict, selectedReadingId: primaryReading.id)
}
}
}
private func ignoreAll() {
for conflict in syncCoordinator.pendingConflicts {
syncCoordinator.ignoreConflict(conflict)
}
}
}
// MARK: - Conflict Row
struct ConflictRow: View {
let conflict: Conflict
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: conflict.dataType.icon)
.foregroundStyle(.blue)
Text(conflict.timeWindow.formattedRange)
.font(.headline)
Spacer()
severityBadge
}
HStack(spacing: 16) {
ForEach(conflict.readings.prefix(3)) { reading in
VStack(alignment: .leading, spacing: 2) {
Text(reading.sourceName)
.font(.caption)
.foregroundStyle(.secondary)
Text(reading.formattedValue)
.font(.subheadline)
.fontWeight(.medium)
}
}
}
if conflict.readings.count > 3 {
Text("+\(conflict.readings.count - 3) weitere")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
private var severityBadge: some View {
Text(conflict.severity.displayName)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(severityColor.opacity(0.2))
.foregroundStyle(severityColor)
.clipShape(Capsule())
}
private var severityColor: Color {
switch conflict.severity {
case .minor: return .green
case .moderate: return .yellow
case .significant: return .orange
case .major: return .red
}
}
}
// MARK: - Conflict Detail View
struct ConflictDetailView: View {
let conflict: Conflict
let onResolve: (UUID) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedReadingId: UUID?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Readings
readingsSection
// Difference Info
differenceSection
Spacer()
}
.padding()
}
.navigationTitle("Konflikt lösen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Auswählen") {
if let id = selectedReadingId {
onResolve(id)
}
}
.disabled(selectedReadingId == nil)
}
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 8) {
Image(systemName: conflict.dataType.icon)
.font(.largeTitle)
.foregroundStyle(.blue)
Text(conflict.dataType.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(conflict.timeWindow.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(conflict.timeWindow.formattedRange)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
// MARK: - Readings
private var readingsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quellen")
.font(.headline)
ForEach(conflict.readings) { reading in
ReadingCard(
reading: reading,
isSelected: selectedReadingId == reading.id,
dataType: conflict.dataType
)
.onTapGesture {
selectedReadingId = reading.id
}
}
}
}
// MARK: - Difference
private var differenceSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Analyse")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Differenz")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f %@", conflict.valueDifference, conflict.dataType.unit))
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Prozentual")
.font(.caption)
.foregroundStyle(.secondary)
Text(String(format: "%.1f%%", conflict.percentageDifference))
.font(.title3)
.fontWeight(.medium)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Reading Card
struct ReadingCard: View {
let reading: SourceReading
let isSelected: Bool
let dataType: HealthDataType
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: reading.sourceCategory.icon)
.foregroundStyle(.blue)
Text(reading.sourceName)
.font(.headline)
}
Text(reading.sourceCategory.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(reading.formattedValue)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .blue : .secondary)
}
.padding()
.background(isSelected ? Color.blue.opacity(0.1) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2)
)
}
}
#Preview {
ConflictsView()
.environmentObject(SyncCoordinator.shared)
}
+42
View File
@@ -0,0 +1,42 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
var body: some View {
TabView(selection: $appState.selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.bar.fill")
}
.tag(AppState.Tab.dashboard)
ConflictsView()
.tabItem {
Label("Konflikte", systemImage: "arrow.triangle.2.circlepath")
}
.tag(AppState.Tab.conflicts)
.badge(syncCoordinator.pendingConflicts.count)
RulesView()
.tabItem {
Label("Regeln", systemImage: "slider.horizontal.3")
}
.tag(AppState.Tab.rules)
SourcesView()
.tabItem {
Label("Quellen", systemImage: "antenna.radiowaves.left.and.right")
}
.tag(AppState.Tab.sources)
}
.tint(.blue)
}
}
#Preview {
ContentView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+379
View File
@@ -0,0 +1,379 @@
import SwiftUI
struct DashboardView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var syncCoordinator: SyncCoordinator
@StateObject private var dataReader = DataReader.shared
@State private var dailySummary: DailySummary?
@State private var isLoading = false
@State private var selectedDate = Date()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Sync Status Card
syncStatusCard
// Date Picker
datePicker
// Health Metrics
if let summary = dailySummary {
healthMetricsGrid(summary: summary)
} else if isLoading {
loadingView
} else {
emptyStateView
}
// Pending Conflicts Alert
if !syncCoordinator.pendingConflicts.isEmpty {
pendingConflictsCard
}
// Recent Sync History
recentSyncHistory
}
.padding()
}
.navigationTitle("HealthBridge")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await performSync() }
} label: {
if syncCoordinator.isSyncing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(syncCoordinator.isSyncing)
}
}
.refreshable {
await loadData()
}
.task {
await loadData()
}
.onChange(of: selectedDate) {
Task { await loadData() }
}
}
}
// MARK: - Sync Status Card
private var syncStatusCard: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Heute")
.font(.headline)
Text(syncCoordinator.todayStats.formattedLastSync)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if syncCoordinator.isSyncing {
VStack(alignment: .trailing) {
ProgressView(value: syncCoordinator.syncProgress)
.frame(width: 60)
Text("Synchronisiere...")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
Divider()
HStack(spacing: 20) {
StatItem(
value: "\(syncCoordinator.todayStats.syncCount)",
label: "Syncs",
icon: "arrow.triangle.2.circlepath"
)
StatItem(
value: "\(syncCoordinator.todayStats.totalConflicts)",
label: "Konflikte",
icon: "exclamationmark.triangle"
)
StatItem(
value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%",
label: "Gelöst",
icon: "checkmark.circle"
)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Date Picker
private var datePicker: some View {
DatePicker(
"Datum",
selection: $selectedDate,
in: ...Date(),
displayedComponents: .date
)
.datePickerStyle(.compact)
.padding(.horizontal)
}
// MARK: - Health Metrics Grid
private func healthMetricsGrid(summary: DailySummary) -> some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
ForEach(HealthDataType.allCases) { dataType in
if let value = summary.values[dataType], value > 0 {
HealthMetricCard(
dataType: dataType,
value: summary.formattedValue(for: dataType),
conflictCount: summary.conflictCounts[dataType] ?? 0
)
}
}
}
}
// MARK: - Pending Conflicts Card
private var pendingConflictsCard: some View {
Button {
appState.selectedTab = .conflicts
} label: {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text("\(syncCoordinator.pendingConflicts.count) Konflikte zu prüfen")
.font(.headline)
Text("Tippen zum Anzeigen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - Recent Sync History
private var recentSyncHistory: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Letzte Synchronisierungen")
.font(.headline)
ForEach(syncCoordinator.syncHistory.prefix(5)) { result in
SyncHistoryRow(result: result)
}
if syncCoordinator.syncHistory.isEmpty {
Text("Keine Synchronisierungen")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, y: 4)
}
// MARK: - Loading & Empty States
private var loadingView: some View {
VStack(spacing: 12) {
ProgressView()
Text("Lade Daten...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var emptyStateView: some View {
VStack(spacing: 12) {
Image(systemName: "heart.text.square")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Keine Daten verfügbar")
.font(.headline)
Text("Synchronisieren Sie, um Daten zu laden")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
// MARK: - Actions
private func loadData() async {
isLoading = true
defer { isLoading = false }
do {
dailySummary = try await dataReader.fetchDailySummary(for: selectedDate)
} catch {
print("Failed to load data: \(error)")
}
}
private func performSync() async {
do {
try await syncCoordinator.performSync(for: selectedDate)
await loadData()
} catch {
print("Sync failed: \(error)")
}
}
}
// MARK: - Supporting Views
struct StatItem: View {
let value: String
let label: String
let icon: String
var body: some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct HealthMetricCard: View {
let dataType: HealthDataType
let value: String
let conflictCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
Spacer()
if conflictCount > 0 {
Text("\(conflictCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.clipShape(Capsule())
}
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
Text(dataType.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
}
struct SyncHistoryRow: View {
let result: SyncResult
var body: some View {
HStack {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
VStack(alignment: .leading, spacing: 2) {
Text(formattedDate)
.font(.subheadline)
Text("\(result.autoResolved)/\(result.totalConflicts) gelöst")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(result.formattedDuration)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private var statusIcon: String {
switch result.status {
case .success: return "checkmark.circle.fill"
case .partialSuccess: return "exclamationmark.circle.fill"
case .failed: return "xmark.circle.fill"
case .inProgress: return "arrow.clockwise"
}
}
private var statusColor: Color {
switch result.status {
case .success: return .green
case .partialSuccess: return .orange
case .failed: return .red
case .inProgress: return .blue
}
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: result.startedAt)
}
}
#Preview {
DashboardView()
.environmentObject(AppState())
.environmentObject(SyncCoordinator.shared)
}
+252
View File
@@ -0,0 +1,252 @@
import SwiftUI
struct RulesView: View {
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedDataType: HealthDataType?
@State private var showingResetConfirmation = false
var body: some View {
NavigationStack {
List {
Section {
Text("Regeln bestimmen, wie Konflikte zwischen verschiedenen Datenquellen automatisch gelöst werden.")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(HealthDataType.allCases) { dataType in
RuleRow(
dataType: dataType,
rule: ruleEngine.getRule(for: dataType)
)
.contentShape(Rectangle())
.onTapGesture {
selectedDataType = dataType
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Regeln")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("Alle zurücksetzen", role: .destructive) {
showingResetConfirmation = true
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(item: $selectedDataType) { dataType in
RuleEditorView(dataType: dataType)
}
.confirmationDialog(
"Alle Regeln zurücksetzen?",
isPresented: $showingResetConfirmation,
titleVisibility: .visible
) {
Button("Zurücksetzen", role: .destructive) {
ruleEngine.resetAllToDefaults()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Regeln werden auf die Standardwerte zurückgesetzt.")
}
}
}
}
// MARK: - Rule Row
struct RuleRow: View {
let dataType: HealthDataType
let rule: MergeRule
var body: some View {
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(dataType.displayName)
.font(.headline)
HStack(spacing: 8) {
Image(systemName: rule.strategy.icon)
.font(.caption)
Text(rule.strategy.displayName)
.font(.caption)
}
.foregroundStyle(.secondary)
}
Spacer()
if !rule.autoApply {
Image(systemName: "hand.raised.fill")
.foregroundStyle(.orange)
.font(.caption)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Rule Editor View
struct RuleEditorView: View {
let dataType: HealthDataType
@Environment(\.dismiss) private var dismiss
@StateObject private var ruleEngine = RuleEngine.shared
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedStrategy: MergeStrategy
@State private var autoApply: Bool
@State private var primarySourceId: String?
@State private var thresholdForManualReview: Double?
@State private var useThreshold: Bool
init(dataType: HealthDataType) {
self.dataType = dataType
let rule = RuleEngine.shared.getRule(for: dataType)
_selectedStrategy = State(initialValue: rule.strategy)
_autoApply = State(initialValue: rule.autoApply)
_primarySourceId = State(initialValue: rule.primarySourceId)
_thresholdForManualReview = State(initialValue: rule.thresholdForManualReview)
_useThreshold = State(initialValue: rule.thresholdForManualReview != nil)
}
var body: some View {
NavigationStack {
Form {
// Data Type Info
Section {
HStack {
Image(systemName: dataType.icon)
.font(.title2)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(dataType.displayName)
.font(.headline)
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// Strategy Selection
Section("Strategie") {
Picker("Merge-Strategie", selection: $selectedStrategy) {
ForEach(MergeStrategy.allCases) { strategy in
Label(strategy.displayName, systemImage: strategy.icon)
.tag(strategy)
}
}
.pickerStyle(.navigationLink)
Text(selectedStrategy.description)
.font(.caption)
.foregroundStyle(.secondary)
}
// Primary Source (for exclusive/priority)
if selectedStrategy == .exclusive || selectedStrategy == .priority {
Section("Primäre Quelle") {
let sources = sourceManager.sources.filter {
$0.supportedDataTypes.contains(dataType)
}
if sources.isEmpty {
Text("Keine Quellen für diesen Datentyp")
.foregroundStyle(.secondary)
} else {
Picker("Quelle", selection: $primarySourceId) {
Text("Automatisch").tag(nil as String?)
ForEach(sources) { source in
Text(source.displayName).tag(source.bundleIdentifier as String?)
}
}
}
}
}
// Auto Apply
Section("Automatisierung") {
Toggle("Automatisch anwenden", isOn: $autoApply)
if autoApply {
Toggle("Schwellenwert für manuelle Prüfung", isOn: $useThreshold)
if useThreshold {
VStack(alignment: .leading) {
Text("Bei Differenz über \(Int(thresholdForManualReview ?? 20))% nachfragen")
.font(.caption)
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { thresholdForManualReview ?? 20 },
set: { thresholdForManualReview = $0 }
),
in: 5...50,
step: 5
)
}
}
}
}
// Reset
Section {
Button("Auf Standard zurücksetzen", role: .destructive) {
let defaultRule = MergeRule.defaultRule(for: dataType)
selectedStrategy = defaultRule.strategy
autoApply = defaultRule.autoApply
primarySourceId = defaultRule.primarySourceId
thresholdForManualReview = defaultRule.thresholdForManualReview
useThreshold = defaultRule.thresholdForManualReview != nil
}
}
}
.navigationTitle("Regel bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Speichern") {
saveRule()
dismiss()
}
}
}
}
}
private func saveRule() {
let rule = MergeRule(
dataType: dataType,
strategy: selectedStrategy,
primarySourceId: primarySourceId,
autoApply: autoApply,
thresholdForManualReview: useThreshold ? thresholdForManualReview : nil
)
ruleEngine.setRule(rule, for: dataType)
}
}
#Preview {
RulesView()
}
+218
View File
@@ -0,0 +1,218 @@
import SwiftUI
struct SettingsView: View {
@AppStorage("backgroundSyncEnabled") private var backgroundSyncEnabled = true
@AppStorage("syncIntervalMinutes") private var syncIntervalMinutes = 15
@AppStorage("notificationsEnabled") private var notificationsEnabled = true
@AppStorage("notifyOnConflict") private var notifyOnConflict = true
@AppStorage("notifyOnSyncComplete") private var notifyOnSyncComplete = false
@AppStorage("autoResolveMinorConflicts") private var autoResolveMinorConflicts = true
@StateObject private var syncCoordinator = SyncCoordinator.shared
@StateObject private var healthKitManager = HealthKitManager.shared
@State private var showingClearDataConfirmation = false
@State private var showingExportSheet = false
var body: some View {
NavigationStack {
Form {
// Sync Settings
Section("Synchronisierung") {
Toggle("Hintergrund-Sync", isOn: $backgroundSyncEnabled)
if backgroundSyncEnabled {
Picker("Intervall", selection: $syncIntervalMinutes) {
Text("15 Minuten").tag(15)
Text("30 Minuten").tag(30)
Text("1 Stunde").tag(60)
Text("2 Stunden").tag(120)
}
}
Toggle("Kleine Konflikte automatisch lösen", isOn: $autoResolveMinorConflicts)
}
// Notification Settings
Section("Benachrichtigungen") {
Toggle("Benachrichtigungen aktivieren", isOn: $notificationsEnabled)
if notificationsEnabled {
Toggle("Bei neuen Konflikten", isOn: $notifyOnConflict)
Toggle("Nach Synchronisierung", isOn: $notifyOnSyncComplete)
}
}
// Health Status
Section("HealthKit Status") {
HStack {
Text("Autorisierung")
Spacer()
if healthKitManager.isAuthorized {
Label("Erteilt", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Label("Ausstehend", systemImage: "exclamationmark.circle")
.foregroundStyle(.orange)
}
}
if !healthKitManager.isAuthorized {
Button("HealthKit-Zugriff anfordern") {
Task {
try? await healthKitManager.requestAuthorization()
}
}
}
}
// Statistics
Section("Statistiken") {
StatRow(label: "Syncs heute", value: "\(syncCoordinator.todayStats.syncCount)")
StatRow(label: "Konflikte heute", value: "\(syncCoordinator.todayStats.totalConflicts)")
StatRow(label: "Automatisch gelöst", value: "\(syncCoordinator.todayStats.autoResolved)")
StatRow(label: "Auflösungsrate", value: "\(Int(syncCoordinator.todayStats.resolutionRate * 100))%")
}
// Data Management
Section("Daten") {
Button("Sync-Verlauf exportieren") {
showingExportSheet = true
}
Button("Sync-Verlauf löschen", role: .destructive) {
showingClearDataConfirmation = true
}
}
// About
Section("Info") {
HStack {
Text("Version")
Spacer()
Text("1.0.0")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://apple.com/health")!) {
HStack {
Text("Apple Health")
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Einstellungen")
.confirmationDialog(
"Sync-Verlauf löschen?",
isPresented: $showingClearDataConfirmation,
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
syncCoordinator.clearHistory()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Der gesamte Sync-Verlauf wird gelöscht. Dies kann nicht rückgängig gemacht werden.")
}
.sheet(isPresented: $showingExportSheet) {
ExportView()
}
}
}
}
// MARK: - Stat Row
struct StatRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Export View
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var syncCoordinator = SyncCoordinator.shared
@State private var exportFormat: ExportFormat = .json
@State private var isExporting = false
enum ExportFormat: String, CaseIterable {
case json = "JSON"
case csv = "CSV"
}
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Daten exportieren")
.font(.title2)
.fontWeight(.semibold)
Picker("Format", selection: $exportFormat) {
ForEach(ExportFormat.allCases, id: \.self) { format in
Text(format.rawValue).tag(format)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
Text("\(syncCoordinator.syncHistory.count) Sync-Einträge")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button {
exportData()
} label: {
Label("Exportieren", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding()
.disabled(isExporting || syncCoordinator.syncHistory.isEmpty)
}
.padding()
.navigationTitle("Export")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") {
dismiss()
}
}
}
}
}
private func exportData() {
isExporting = true
// In a real app, this would create and share a file
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isExporting = false
dismiss()
}
}
}
#Preview {
SettingsView()
}
+407
View File
@@ -0,0 +1,407 @@
import SwiftUI
struct SourcesView: View {
@StateObject private var sourceManager = SourceManager.shared
@State private var selectedSource: HealthSource?
@State private var isRefreshing = false
var body: some View {
NavigationStack {
Group {
if sourceManager.sources.isEmpty && !sourceManager.isDiscovering {
emptyState
} else {
sourcesList
}
}
.navigationTitle("Quellen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await refreshSources() }
} label: {
if isRefreshing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.sheet(item: $selectedSource) { source in
SourceDetailView(source: source)
}
.task {
if sourceManager.sources.isEmpty {
await refreshSources()
}
}
}
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Keine Quellen gefunden")
.font(.title2)
.fontWeight(.semibold)
Text("Verbinden Sie Geräte mit Apple Health")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
Task { await refreshSources() }
} label: {
Label("Aktualisieren", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
.padding()
}
// MARK: - Sources List
private var sourcesList: some View {
List {
ForEach(groupedSources.keys.sorted(by: { $0.priority > $1.priority }), id: \.self) { category in
Section(category.displayName) {
ForEach(groupedSources[category] ?? []) { source in
SourceRow(source: source)
.contentShape(Rectangle())
.onTapGesture {
selectedSource = source
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable {
await refreshSources()
}
}
private var groupedSources: [SourceCategory: [HealthSource]] {
Dictionary(grouping: sourceManager.sources, by: { $0.category })
}
private func refreshSources() async {
isRefreshing = true
defer { isRefreshing = false }
await sourceManager.discoverSources()
}
}
// MARK: - Source Row
struct SourceRow: View {
let source: HealthSource
@StateObject private var sourceManager = SourceManager.shared
var body: some View {
HStack {
Image(systemName: source.category.icon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 40)
VStack(alignment: .leading, spacing: 4) {
Text(source.displayName)
.font(.headline)
Text("\(source.supportedDataTypes.count) Datentypen")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let status = sourceManager.sourceHealthStatus[source.id] {
Image(systemName: status.syncStatus.icon)
.foregroundStyle(statusColor(for: status.syncStatus))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private func statusColor(for status: SourceHealthStatus.SyncStatus) -> Color {
switch status {
case .recentlySynced: return .green
case .syncedToday: return .blue
case .stale: return .orange
case .veryStale, .neverSynced: return .red
}
}
}
// MARK: - Source Detail View
struct SourceDetailView: View {
let source: HealthSource
@Environment(\.dismiss) private var dismiss
@StateObject private var sourceManager = SourceManager.shared
@State private var healthReport: SourceHealthReport?
@State private var isLoading = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Header
headerSection
// Capabilities
capabilitiesSection
// Data Types
dataTypesSection
// Health Report
if let report = healthReport {
healthReportSection(report)
}
// Priority Settings
prioritySection
}
.padding()
}
.navigationTitle(source.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") {
dismiss()
}
}
}
.task {
await loadHealthReport()
}
}
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 12) {
Image(systemName: source.category.icon)
.font(.system(size: 48))
.foregroundStyle(.blue)
Text(source.displayName)
.font(.title2)
.fontWeight(.semibold)
Text(source.category.displayName)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(source.bundleIdentifier)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding()
}
// MARK: - Capabilities
private var capabilitiesSection: some View {
let capabilities = sourceManager.getSourceCapabilities(source)
return VStack(alignment: .leading, spacing: 12) {
Text("Fähigkeiten")
.font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
CapabilityBadge(name: "Schritte", available: capabilities.canMeasureSteps)
CapabilityBadge(name: "Herzfrequenz", available: capabilities.canMeasureHeartRate)
CapabilityBadge(name: "Blutdruck", available: capabilities.canMeasureBloodPressure)
CapabilityBadge(name: "SpO2", available: capabilities.canMeasureBloodOxygen)
CapabilityBadge(name: "Schlaf", available: capabilities.canMeasureSleep)
CapabilityBadge(name: "GPS", available: capabilities.hasGPS)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Data Types
private var dataTypesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Unterstützte Datentypen")
.font(.headline)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Image(systemName: dataType.icon)
.foregroundStyle(.blue)
.frame(width: 24)
Text(dataType.displayName)
Spacer()
Text(dataType.unit)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Health Report
private func healthReportSection(_ report: SourceHealthReport) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Zustand")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("Datensätze (24h)")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(report.totalRecordCount)")
.font(.title3)
.fontWeight(.medium)
}
Spacer()
VStack(alignment: .trailing) {
Text("Qualität")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: report.overallQuality.icon)
.font(.title3)
.foregroundStyle(qualityColor(report.overallQuality))
}
}
if let lastActivity = report.lastOverallActivity {
HStack {
Text("Letzte Aktivität")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formattedDate(lastActivity))
.font(.subheadline)
}
}
if report.hasSignificantGaps {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Datenlücken erkannt")
.font(.subheadline)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Priority Section
private var prioritySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Priorität")
.font(.headline)
Text("Höhere Priorität bedeutet, dass Daten dieser Quelle bevorzugt werden")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(Array(source.supportedDataTypes).sorted(by: { $0.displayName < $1.displayName }), id: \.self) { dataType in
HStack {
Text(dataType.displayName)
Spacer()
Stepper(
"\(sourceManager.getPriority(for: source, dataType: dataType))",
value: Binding(
get: { sourceManager.getPriority(for: source, dataType: dataType) },
set: { sourceManager.setPriority($0, for: source, dataType: dataType) }
),
in: 0...100,
step: 10
)
.frame(width: 150)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Helpers
private func loadHealthReport() async {
isLoading = true
defer { isLoading = false }
healthReport = await sourceManager.getSourceHealth(source)
}
private func qualityColor(_ quality: DataQuality) -> Color {
switch quality {
case .complete: return .green
case .partial: return .yellow
case .missing: return .gray
case .invalid: return .red
}
}
private func formattedDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Capability Badge
struct CapabilityBadge: View {
let name: String
let available: Bool
var body: some View {
HStack(spacing: 4) {
Image(systemName: available ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(available ? .green : .secondary)
Text(name)
.font(.caption)
.foregroundStyle(available ? .primary : .secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(available ? Color.green.opacity(0.1) : Color.gray.opacity(0.1))
.clipShape(Capsule())
}
}
#Preview {
SourcesView()
}
+120
View File
@@ -0,0 +1,120 @@
//
// AppDelegate.swift
// PsytranceVisualizer
//
// Application delegate handling app lifecycle
//
import AppKit
import AVFoundation
/// Application delegate
final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Properties
private var mainWindowController: MainWindowController?
// MARK: - App Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
// Request microphone permission
requestMicrophonePermission()
// Create and show main window
mainWindowController = MainWindowController()
mainWindowController?.showWindow(nil)
mainWindowController?.window?.makeKeyAndOrderFront(nil)
// Activate the application
NSApp.activate(ignoringOtherApps: true)
print("[AppDelegate] Application launched")
}
func applicationWillTerminate(_ notification: Notification) {
// Save settings
SettingsManager.shared.saveNow()
print("[AppDelegate] Application terminating")
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
}
// MARK: - Permissions
private func requestMicrophonePermission() {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
print("[AppDelegate] Microphone access already authorized")
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
if granted {
print("[AppDelegate] Microphone access granted")
} else {
print("[AppDelegate] Microphone access denied")
self.showMicrophonePermissionAlert()
}
}
case .denied, .restricted:
print("[AppDelegate] Microphone access denied or restricted")
showMicrophonePermissionAlert()
@unknown default:
break
}
}
private func showMicrophonePermissionAlert() {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "Microphone Access Required"
alert.informativeText = "Psytrance Visualizer needs access to your audio input to visualize music. Please enable microphone access in System Preferences > Security & Privacy > Privacy > Microphone."
alert.alertStyle = .warning
alert.addButton(withTitle: "Open System Preferences")
alert.addButton(withTitle: "Cancel")
if alert.runModal() == .alertFirstButtonReturn {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") {
NSWorkspace.shared.open(url)
}
}
}
}
// MARK: - Menu Actions
@IBAction func showAbout(_ sender: Any) {
let alert = NSAlert()
alert.messageText = "Psytrance Visualizer"
alert.informativeText = """
An audio-reactive visualizer for psytrance music.
8 Visualization Modes:
1 - FFT Classic
2 - Mel Spectrogram
3 - Sub-Bass
4 - Sidechain Pump
5 - Harmonic/Noise
6 - Mandelbrot
7 - Tunnel Warp
8 - DMT Geometry
Keyboard Shortcuts:
1-8: Switch visualization mode
F: Toggle fullscreen
ESC: Exit fullscreen
Tip: Use a virtual audio device like BlackHole to route system audio.
"""
alert.alertStyle = .informational
alert.runModal()
}
}
@@ -0,0 +1,133 @@
//
// PsytranceVisualizerApp.swift
// PsytranceVisualizer
//
// Main application entry point
//
import AppKit
// MARK: - Main Entry Point
/// Application entry point
@main
struct PsytranceVisualizerApp {
static func main() {
// Create the application
let app = NSApplication.shared
// Set up the delegate
let delegate = AppDelegate()
app.delegate = delegate
// Set activation policy
app.setActivationPolicy(.regular)
// Create the main menu
setupMainMenu()
// Run the application
app.run()
}
/// Sets up the application's main menu
private static func setupMainMenu() {
let mainMenu = NSMenu()
// Application menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About Psytrance Visualizer",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Hide Psytrance Visualizer",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
let hideOthersItem = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthersItem.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(withTitle: "Show All",
action: #selector(NSApplication.unhideAllApplications(_:)),
keyEquivalent: "")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Quit Psytrance Visualizer",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
// View menu
let viewMenuItem = NSMenuItem()
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
viewMenu.addItem(withTitle: "Toggle Fullscreen",
action: #selector(NSWindow.toggleFullScreen(_:)),
keyEquivalent: "f")
viewMenu.addItem(NSMenuItem.separator())
// Visualization mode submenu
let modesMenuItem = NSMenuItem(title: "Visualization Mode", action: nil, keyEquivalent: "")
let modesMenu = NSMenu()
for mode in VisualizationMode.allCases {
let item = NSMenuItem(title: mode.displayName,
action: nil,
keyEquivalent: mode.shortcut)
item.tag = mode.rawValue
modesMenu.addItem(item)
}
modesMenuItem.submenu = modesMenu
viewMenu.addItem(modesMenuItem)
// Window menu
let windowMenuItem = NSMenuItem()
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(withTitle: "Minimize",
action: #selector(NSWindow.miniaturize(_:)),
keyEquivalent: "m")
windowMenu.addItem(withTitle: "Zoom",
action: #selector(NSWindow.zoom(_:)),
keyEquivalent: "")
windowMenu.addItem(NSMenuItem.separator())
windowMenu.addItem(withTitle: "Bring All to Front",
action: #selector(NSApplication.arrangeInFront(_:)),
keyEquivalent: "")
// Help menu
let helpMenuItem = NSMenuItem()
mainMenu.addItem(helpMenuItem)
let helpMenu = NSMenu(title: "Help")
helpMenuItem.submenu = helpMenu
helpMenu.addItem(withTitle: "Psytrance Visualizer Help",
action: #selector(AppDelegate.showAbout(_:)),
keyEquivalent: "?")
NSApp.mainMenu = mainMenu
NSApp.windowsMenu = windowMenu
NSApp.helpMenu = helpMenu
}
}
@@ -0,0 +1,357 @@
//
// AudioInputManager.swift
// PsytranceVisualizer
//
// Manages audio input devices and captures audio buffers
//
import AVFoundation
import CoreAudio
import Combine
/// Represents an audio input device
struct AudioDevice: Identifiable, Hashable {
let id: AudioDeviceID
let uid: String
let name: String
let manufacturer: String
let isInput: Bool
func hash(into hasher: inout Hasher) {
hasher.combine(uid)
}
static func == (lhs: AudioDevice, rhs: AudioDevice) -> Bool {
lhs.uid == rhs.uid
}
}
/// Manages audio input capture using AVAudioEngine
final class AudioInputManager: ObservableObject {
// MARK: - Published Properties
@Published private(set) var availableDevices: [AudioDevice] = []
@Published private(set) var selectedDevice: AudioDevice?
@Published private(set) var isRunning = false
@Published private(set) var currentBufferSize: Int = 1024
// MARK: - Audio Properties
private var audioEngine: AVAudioEngine?
private var inputNode: AVAudioInputNode?
private let sampleRate: Double = 44100.0
// MARK: - Callbacks
var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)?
// MARK: - Private Properties
private var deviceListenerBlock: AudioObjectPropertyListenerBlock?
private let processingQueue = DispatchQueue(label: "com.psytrance.audio", qos: .userInteractive)
// MARK: - Initialization
init() {
refreshDeviceList()
setupDeviceChangeListener()
}
deinit {
stop()
removeDeviceChangeListener()
}
// MARK: - Public Methods
/// Returns list of available audio input devices
func getAvailableInputDevices() -> [AudioDevice] {
return availableDevices
}
/// Refreshes the list of available audio input devices
func refreshDeviceList() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list size: \(status)")
return
}
let deviceCount = Int(dataSize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
&dataSize,
&deviceIDs
)
guard status == noErr else {
print("[AudioInputManager] Failed to get device list: \(status)")
return
}
var devices: [AudioDevice] = []
for deviceID in deviceIDs {
if let device = getDeviceInfo(deviceID: deviceID), device.isInput {
devices.append(device)
}
}
DispatchQueue.main.async {
self.availableDevices = devices
print("[AudioInputManager] Found \(devices.count) input devices")
}
}
/// Selects an audio input device by UID
func selectDevice(uid: String) {
guard let device = availableDevices.first(where: { $0.uid == uid }) else {
print("[AudioInputManager] Device not found: \(uid)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
selectedDevice = device
setSystemInputDevice(deviceID: device.id)
if wasRunning {
start()
}
print("[AudioInputManager] Selected device: \(device.name)")
}
/// Sets the buffer size (512 or 1024)
func setBufferSize(_ size: Int) {
guard [512, 1024].contains(size) else {
print("[AudioInputManager] Invalid buffer size: \(size)")
return
}
let wasRunning = isRunning
if wasRunning {
stop()
}
currentBufferSize = size
if wasRunning {
start()
}
print("[AudioInputManager] Buffer size set to: \(size)")
}
/// Starts audio capture
func start() {
guard !isRunning else { return }
do {
// Create new audio engine
audioEngine = AVAudioEngine()
guard let engine = audioEngine else { return }
inputNode = engine.inputNode
guard let inputNode = inputNode else {
print("[AudioInputManager] No input node available")
return
}
// Get the input format
let inputFormat = inputNode.outputFormat(forBus: 0)
print("[AudioInputManager] Input format: \(inputFormat)")
// Install tap on input node
let bufferSize = AVAudioFrameCount(currentBufferSize)
inputNode.installTap(onBus: 0, bufferSize: bufferSize, format: inputFormat) { [weak self] buffer, _ in
self?.processingQueue.async {
self?.onAudioBuffer?(buffer)
}
}
// Prepare and start the engine
engine.prepare()
try engine.start()
DispatchQueue.main.async {
self.isRunning = true
}
print("[AudioInputManager] Audio capture started")
} catch {
print("[AudioInputManager] Failed to start audio capture: \(error)")
}
}
/// Stops audio capture
func stop() {
guard isRunning else { return }
inputNode?.removeTap(onBus: 0)
audioEngine?.stop()
audioEngine = nil
inputNode = nil
DispatchQueue.main.async {
self.isRunning = false
}
print("[AudioInputManager] Audio capture stopped")
}
// MARK: - Private Methods
/// Gets device info for a specific device ID
private func getDeviceInfo(deviceID: AudioDeviceID) -> AudioDevice? {
// Check if device has input channels
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain
)
var dataSize: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &dataSize)
guard status == noErr, dataSize > 0 else { return nil }
let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(dataSize))
defer { bufferListPointer.deallocate() }
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &dataSize, bufferListPointer)
guard status == noErr else { return nil }
let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer)
var inputChannelCount: UInt32 = 0
for buffer in bufferList {
inputChannelCount += buffer.mNumberChannels
}
guard inputChannelCount > 0 else { return nil }
// Get device UID
var uid: CFString = "" as CFString
var uidSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceUID
propertyAddress.mScope = kAudioObjectPropertyScopeGlobal
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &uidSize, &uid)
guard status == noErr else { return nil }
// Get device name
var name: CFString = "" as CFString
var nameSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &nameSize, &name)
guard status == noErr else { return nil }
// Get manufacturer
var manufacturer: CFString = "" as CFString
var manufacturerSize = UInt32(MemoryLayout<CFString>.size)
propertyAddress.mSelector = kAudioDevicePropertyDeviceManufacturerCFString
AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &manufacturerSize, &manufacturer)
return AudioDevice(
id: deviceID,
uid: uid as String,
name: name as String,
manufacturer: manufacturer as String,
isInput: true
)
}
/// Sets the system default input device
private func setSystemInputDevice(deviceID: AudioDeviceID) {
var deviceIDCopy = deviceID
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
0,
nil,
UInt32(MemoryLayout<AudioDeviceID>.size),
&deviceIDCopy
)
if status != noErr {
print("[AudioInputManager] Failed to set input device: \(status)")
}
}
/// Sets up listener for device changes
private func setupDeviceChangeListener() {
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
deviceListenerBlock = { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshDeviceList()
}
}
if let block = deviceListenerBlock {
AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
/// Removes device change listener
private func removeDeviceChangeListener() {
guard let block = deviceListenerBlock else { return }
var propertyAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&propertyAddress,
DispatchQueue.main,
block
)
}
}
+468
View File
@@ -0,0 +1,468 @@
//
// DSPEngine.swift
// PsytranceVisualizer
//
// Digital Signal Processing engine for audio analysis
//
import Accelerate
import AVFoundation
/// DSP Engine for real-time audio analysis
final class DSPEngine {
// MARK: - Configuration
private let sampleRate: Float = 44100.0
private var fftSize: Int
private let melBandCount: Int = 64
private let subBassUpperFreq: Float = 100.0
private let historySize: Int = 128
// MARK: - FFT Setup
private var fftSetup: vDSP_DFT_Setup?
private var window: [Float]
private var realPart: [Float]
private var imagPart: [Float]
private var magnitudes: [Float]
// MARK: - Mel Filterbank
private var melFilterbank: [[Float]]
private var melOutput: [Float]
// MARK: - Analysis State
private var subBassHistory: [Float]
private var previousMagnitudes: [Float]
private var envelopeValue: Float = 0
private var previousEnvelope: Float = 0
private var pumpHistory: [Float]
private var lastPeakTime: Double = 0
private var peakThreshold: Float = 0.3
// MARK: - Reactivity
private var reactivity: Float = 0.5
private var smoothingFactor: Float = 0.3
// MARK: - Initialization
init(bufferSize: Int = 1024) {
self.fftSize = bufferSize
// Initialize FFT arrays
self.window = [Float](repeating: 0, count: fftSize)
self.realPart = [Float](repeating: 0, count: fftSize)
self.imagPart = [Float](repeating: 0, count: fftSize)
self.magnitudes = [Float](repeating: 0, count: fftSize / 2)
self.previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Initialize Mel arrays
self.melOutput = [Float](repeating: 0, count: melBandCount)
self.melFilterbank = []
// Initialize history arrays
self.subBassHistory = [Float](repeating: 0, count: historySize)
self.pumpHistory = [Float](repeating: 0, count: 64)
// Create Hann window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Create FFT setup
fftSetup = vDSP_DFT_zop_CreateSetup(
nil,
vDSP_Length(fftSize),
.FORWARD
)
// Build Mel filterbank
buildMelFilterbank()
}
deinit {
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
}
// MARK: - Public Methods
/// Sets reactivity value (0.0 - 1.0)
func setReactivity(_ value: Float) {
reactivity = max(0.0, min(1.0, value))
// Adjust smoothing based on reactivity (higher reactivity = less smoothing)
smoothingFactor = 0.1 + (1.0 - reactivity) * 0.4
}
/// Reconfigures for new buffer size
func setBufferSize(_ size: Int) {
guard size != fftSize else { return }
fftSize = size
// Reinitialize arrays
window = [Float](repeating: 0, count: fftSize)
realPart = [Float](repeating: 0, count: fftSize)
imagPart = [Float](repeating: 0, count: fftSize)
magnitudes = [Float](repeating: 0, count: fftSize / 2)
previousMagnitudes = [Float](repeating: 0, count: fftSize / 2)
// Recreate window
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
// Recreate FFT setup
if let setup = fftSetup {
vDSP_DFT_DestroySetup(setup)
}
fftSetup = vDSP_DFT_zop_CreateSetup(nil, vDSP_Length(fftSize), .FORWARD)
// Rebuild filterbank
buildMelFilterbank()
}
/// Processes audio buffer and returns analysis data
func process(buffer: AVAudioPCMBuffer) -> AudioAnalysisData {
guard let channelData = buffer.floatChannelData else {
return .empty
}
let frameCount = Int(buffer.frameLength)
let channelCount = Int(buffer.format.channelCount)
// Extract stereo channels
var leftChannel = [Float](repeating: 0, count: frameCount)
var rightChannel = [Float](repeating: 0, count: frameCount)
if channelCount >= 1 {
leftChannel = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount))
}
if channelCount >= 2 {
rightChannel = Array(UnsafeBufferPointer(start: channelData[1], count: frameCount))
} else {
rightChannel = leftChannel
}
// Mix to mono for analysis
var monoBuffer = [Float](repeating: 0, count: frameCount)
vDSP_vadd(leftChannel, 1, rightChannel, 1, &monoBuffer, 1, vDSP_Length(frameCount))
var half: Float = 0.5
vDSP_vsmul(monoBuffer, 1, &half, &monoBuffer, 1, vDSP_Length(frameCount))
// Calculate RMS
var rmsValue: Float = 0
vDSP_rmsqv(monoBuffer, 1, &rmsValue, vDSP_Length(frameCount))
// Perform FFT
let fftMagnitudes = performFFT(monoBuffer)
// Calculate Mel bands
let melBands = calculateMelBands(from: fftMagnitudes)
// Extract sub-bass
let subBassEnergy = calculateSubBassEnergy(from: fftMagnitudes)
// Update sub-bass history
subBassHistory.removeFirst()
subBassHistory.append(subBassEnergy)
// Calculate sidechain envelope and pump detection
let (envelope, pumpAmount, isPumping) = detectSidechainPump(subBassEnergy: subBassEnergy)
// Calculate HNR
let hnrRatio = calculateHNR(buffer: monoBuffer)
// Detect peaks/transients
let (isPeak, peakIntensity) = detectPeak(rms: rmsValue)
// Calculate spectral centroid
let spectralCentroid = calculateSpectralCentroid(magnitudes: fftMagnitudes)
return AudioAnalysisData(
fftMagnitudes: fftMagnitudes,
melBands: melBands,
subBassEnergy: subBassEnergy,
subBassHistory: subBassHistory,
sidechainEnvelope: envelope,
sidechainPumpAmount: pumpAmount,
isPumping: isPumping,
hnrRatio: hnrRatio,
isPeak: isPeak,
peakIntensity: peakIntensity,
leftChannel: leftChannel,
rightChannel: rightChannel,
spectralCentroid: spectralCentroid,
rmsLevel: rmsValue
)
}
// MARK: - FFT
private func performFFT(_ buffer: [Float]) -> [Float] {
guard let setup = fftSetup else { return magnitudes }
let count = min(buffer.count, fftSize)
// Apply window
var windowedBuffer = [Float](repeating: 0, count: fftSize)
for i in 0..<count {
windowedBuffer[i] = buffer[i] * window[i]
}
// Prepare for DFT (separate into real and imaginary)
for i in 0..<fftSize {
realPart[i] = windowedBuffer[i]
imagPart[i] = 0
}
// Perform DFT
var outputReal = [Float](repeating: 0, count: fftSize)
var outputImag = [Float](repeating: 0, count: fftSize)
vDSP_DFT_Execute(setup, realPart, imagPart, &outputReal, &outputImag)
// Calculate magnitudes
let halfSize = fftSize / 2
var newMagnitudes = [Float](repeating: 0, count: halfSize)
for i in 0..<halfSize {
let real = outputReal[i]
let imag = outputImag[i]
newMagnitudes[i] = sqrt(real * real + imag * imag) / Float(fftSize)
}
// Apply smoothing
for i in 0..<halfSize {
magnitudes[i] = magnitudes[i] * smoothingFactor + newMagnitudes[i] * (1.0 - smoothingFactor)
}
previousMagnitudes = magnitudes
return magnitudes
}
// MARK: - Mel Filterbank
private func buildMelFilterbank() {
let halfFFT = fftSize / 2
let nyquist = sampleRate / 2.0
// Mel scale conversion
func hzToMel(_ hz: Float) -> Float {
return 2595.0 * log10(1.0 + hz / 700.0)
}
func melToHz(_ mel: Float) -> Float {
return 700.0 * (pow(10.0, mel / 2595.0) - 1.0)
}
let melMin = hzToMel(20.0)
let melMax = hzToMel(nyquist)
// Create mel points
var melPoints = [Float](repeating: 0, count: melBandCount + 2)
for i in 0..<melBandCount + 2 {
melPoints[i] = melMin + Float(i) * (melMax - melMin) / Float(melBandCount + 1)
}
// Convert back to Hz
var hzPoints = melPoints.map { melToHz($0) }
// Convert to FFT bins
var binPoints = hzPoints.map { Int($0 / nyquist * Float(halfFFT)) }
// Build triangular filters
melFilterbank = []
for m in 1...melBandCount {
var filter = [Float](repeating: 0, count: halfFFT)
let startBin = binPoints[m - 1]
let centerBin = binPoints[m]
let endBin = binPoints[m + 1]
// Rising edge
for k in startBin..<centerBin {
if centerBin != startBin {
filter[k] = Float(k - startBin) / Float(centerBin - startBin)
}
}
// Falling edge
for k in centerBin..<endBin {
if endBin != centerBin {
filter[k] = Float(endBin - k) / Float(endBin - centerBin)
}
}
melFilterbank.append(filter)
}
}
private func calculateMelBands(from magnitudes: [Float]) -> [Float] {
var result = [Float](repeating: 0, count: melBandCount)
for (i, filter) in melFilterbank.enumerated() {
var sum: Float = 0
let count = min(filter.count, magnitudes.count)
for j in 0..<count {
sum += magnitudes[j] * filter[j]
}
// Apply logarithmic scaling
result[i] = log10(1.0 + sum * 10.0) / log10(11.0)
}
// Apply smoothing to mel output
for i in 0..<melBandCount {
melOutput[i] = melOutput[i] * smoothingFactor + result[i] * (1.0 - smoothingFactor)
}
return melOutput
}
// MARK: - Sub-Bass Analysis
private func calculateSubBassEnergy(from magnitudes: [Float]) -> Float {
let binFrequency = sampleRate / Float(fftSize)
let subBassBinCount = Int(subBassUpperFreq / binFrequency)
guard subBassBinCount > 0, magnitudes.count >= subBassBinCount else { return 0 }
var sum: Float = 0
for i in 0..<subBassBinCount {
sum += magnitudes[i] * magnitudes[i]
}
let rms = sqrt(sum / Float(subBassBinCount))
// Normalize and apply gain
let normalized = min(1.0, rms * 5.0 * (1.0 + reactivity))
return normalized
}
// MARK: - Sidechain Pump Detection
private func detectSidechainPump(subBassEnergy: Float) -> (envelope: Float, pumpAmount: Float, isPumping: Bool) {
// Envelope follower with fast attack, slow release
let attackTime: Float = 0.005 // 5ms attack
let releaseTime: Float = 0.15 // 150ms release
let attackCoeff = exp(-1.0 / (sampleRate * attackTime))
let releaseCoeff = exp(-1.0 / (sampleRate * releaseTime))
if subBassEnergy > envelopeValue {
envelopeValue = attackCoeff * envelopeValue + (1.0 - attackCoeff) * subBassEnergy
} else {
envelopeValue = releaseCoeff * envelopeValue + (1.0 - releaseCoeff) * subBassEnergy
}
// Update pump history
pumpHistory.removeFirst()
pumpHistory.append(envelopeValue)
// Analyze pump periodicity
var pumpAmount: Float = 0
var isPumping = false
// Look for characteristic pump pattern (rise and fall)
let derivative = envelopeValue - previousEnvelope
previousEnvelope = envelopeValue
// Detect pump by finding periodic envelope variations
if pumpHistory.count >= 32 {
let recent = Array(pumpHistory.suffix(32))
var variance: Float = 0
let mean = recent.reduce(0, +) / Float(recent.count)
for value in recent {
variance += (value - mean) * (value - mean)
}
variance /= Float(recent.count)
// Higher variance = more pumping
pumpAmount = min(1.0, sqrt(variance) * 4.0)
isPumping = pumpAmount > 0.3 && abs(derivative) > 0.02
}
return (envelopeValue, pumpAmount, isPumping)
}
// MARK: - HNR Calculation
private func calculateHNR(buffer: [Float]) -> Float {
// Use autocorrelation to estimate harmonicity
let frameSize = min(buffer.count, 512)
var autocorr = [Float](repeating: 0, count: frameSize)
// Compute autocorrelation
vDSP_conv(buffer, 1, buffer, 1, &autocorr, 1, vDSP_Length(frameSize), vDSP_Length(frameSize))
// Find the peak in autocorrelation (excluding lag 0)
let minLag = 20 // Minimum lag to avoid DC component
let maxLag = min(frameSize - 1, 400) // Maximum lag
guard maxLag > minLag else { return 0.5 }
var maxValue: Float = 0
var maxIndex: vDSP_Length = 0
let searchRange = Array(autocorr[minLag...maxLag])
vDSP_maxvi(searchRange, 1, &maxValue, &maxIndex, vDSP_Length(searchRange.count))
// Calculate HNR as ratio of peak to first value
let noiseFloor = autocorr.suffix(from: maxLag).reduce(0) { $0 + abs($1) } / Float(frameSize - maxLag)
let harmonicPower = maxValue
let noisePower = max(noiseFloor, 0.0001)
// Convert to 0-1 range
let hnr = harmonicPower / (harmonicPower + noisePower)
return max(0.0, min(1.0, hnr))
}
// MARK: - Peak Detection
private var previousRMS: Float = 0
private var rmsHistory: [Float] = Array(repeating: 0, count: 16)
private func detectPeak(rms: Float) -> (isPeak: Bool, intensity: Float) {
// Update history
rmsHistory.removeFirst()
rmsHistory.append(rms)
// Calculate moving average
let average = rmsHistory.reduce(0, +) / Float(rmsHistory.count)
// Detect sudden increase
let increase = rms - previousRMS
let threshold = average * (0.5 + reactivity * 0.5)
previousRMS = rms
let isPeak = increase > threshold && rms > average * 1.5
let intensity = isPeak ? min(1.0, increase / max(average, 0.01) * 2.0) : 0
return (isPeak, intensity)
}
// MARK: - Spectral Centroid
private func calculateSpectralCentroid(magnitudes: [Float]) -> Float {
var weightedSum: Float = 0
var sum: Float = 0
for (i, mag) in magnitudes.enumerated() {
weightedSum += Float(i) * mag
sum += mag
}
guard sum > 0 else { return 0.5 }
let centroid = weightedSum / sum
let normalized = centroid / Float(magnitudes.count)
return max(0.0, min(1.0, normalized))
}
}
@@ -0,0 +1,90 @@
//
// AppSettings.swift
// PsytranceVisualizer
//
// Persistent application settings
//
import Foundation
/// Application settings that are persisted between sessions
struct AppSettings: Codable {
/// Selected audio input device UID
var selectedAudioDeviceUID: String?
/// Audio buffer size (512 or 1024 samples)
var bufferSize: Int
/// Last used visualization mode (1-8)
var lastVisualizationMode: Int
/// Reactivity slider value (0.0 - 1.0)
var reactivity: Float
/// Whether app was in fullscreen mode
var isFullscreen: Bool
/// Last window frame (for restoration)
var windowFrame: CodableRect?
/// Volume/gain adjustment
var inputGain: Float
/// Whether to show FPS counter
var showFPS: Bool
/// Default settings
static var `default`: AppSettings {
AppSettings(
selectedAudioDeviceUID: nil,
bufferSize: 1024,
lastVisualizationMode: 1,
reactivity: 0.5,
isFullscreen: false,
windowFrame: nil,
inputGain: 1.0,
showFPS: false
)
}
/// Available buffer sizes
static let availableBufferSizes = [512, 1024]
/// Validates and clamps settings to valid ranges
mutating func validate() {
// Clamp buffer size to valid options
if !AppSettings.availableBufferSizes.contains(bufferSize) {
bufferSize = 1024
}
// Clamp visualization mode
if lastVisualizationMode < 1 || lastVisualizationMode > 8 {
lastVisualizationMode = 1
}
// Clamp reactivity
reactivity = max(0.0, min(1.0, reactivity))
// Clamp input gain
inputGain = max(0.0, min(2.0, inputGain))
}
}
/// Codable wrapper for CGRect
struct CodableRect: Codable {
var x: Double
var y: Double
var width: Double
var height: Double
init(from rect: CGRect) {
self.x = Double(rect.origin.x)
self.y = Double(rect.origin.y)
self.width = Double(rect.size.width)
self.height = Double(rect.size.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
@@ -0,0 +1,111 @@
//
// AudioAnalysisData.swift
// PsytranceVisualizer
//
// Audio analysis data structure containing all DSP results
//
import Foundation
/// Contains all audio analysis data computed by DSPEngine
struct AudioAnalysisData {
// MARK: - FFT Data
/// Raw FFT magnitude spectrum
var fftMagnitudes: [Float]
// MARK: - Mel Spectrogram
/// 64 Mel frequency bands
var melBands: [Float]
// MARK: - Sub-Bass Analysis
/// RMS energy below 100Hz (0.0 - 1.0)
var subBassEnergy: Float
/// History buffer for time-based visualization
var subBassHistory: [Float]
// MARK: - Sidechain Detection
/// Current envelope follower value (0.0 - 1.0)
var sidechainEnvelope: Float
/// Detected pumping amount (0.0 - 1.0)
var sidechainPumpAmount: Float
/// Whether pump is currently active
var isPumping: Bool
// MARK: - Harmonic-to-Noise Ratio
/// HNR ratio (0.0 = noise, 1.0 = pure harmonic)
var hnrRatio: Float
// MARK: - Transient Detection
/// Whether a transient peak was detected
var isPeak: Bool
/// Intensity of the detected peak (0.0 - 1.0)
var peakIntensity: Float
// MARK: - Stereo Channels
/// Left channel samples
var leftChannel: [Float]
/// Right channel samples
var rightChannel: [Float]
// MARK: - Additional Analysis
/// Spectral centroid (brightness) normalized 0.0 - 1.0
var spectralCentroid: Float
/// Overall RMS level
var rmsLevel: Float
// MARK: - Initialization
/// Creates an empty AudioAnalysisData with default values
static var empty: AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: [],
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: [],
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
/// Creates AudioAnalysisData with specified FFT size
static func create(fftSize: Int) -> AudioAnalysisData {
AudioAnalysisData(
fftMagnitudes: Array(repeating: 0, count: fftSize / 2),
melBands: Array(repeating: 0, count: 64),
subBassEnergy: 0,
subBassHistory: Array(repeating: 0, count: 128),
sidechainEnvelope: 0,
sidechainPumpAmount: 0,
isPumping: false,
hnrRatio: 0.5,
isPeak: false,
peakIntensity: 0,
leftChannel: [],
rightChannel: [],
spectralCentroid: 0.5,
rmsLevel: 0
)
}
}
@@ -0,0 +1,109 @@
//
// VisualizationMode.swift
// PsytranceVisualizer
//
// Enumeration of all available visualization modes
//
import Foundation
/// Available visualization modes, accessible via keyboard shortcuts 1-8
enum VisualizationMode: Int, CaseIterable, Codable {
case fftClassic = 1
case melSpectrogram = 2
case subBass = 3
case sidechainPump = 4
case hnr = 5
case mandelbrot = 6
case tunnelWarp = 7
case dmtGeometry = 8
/// Display name for UI
var displayName: String {
switch self {
case .fftClassic:
return "FFT Classic"
case .melSpectrogram:
return "Mel Spektrogramm"
case .subBass:
return "Sub-Bass (<100Hz)"
case .sidechainPump:
return "Sidechain Pump"
case .hnr:
return "Harmonic/Noise"
case .mandelbrot:
return "Mandelbrot"
case .tunnelWarp:
return "Tunnel Warp"
case .dmtGeometry:
return "DMT Geometry"
}
}
/// Keyboard shortcut (1-8)
var shortcut: String {
return "\(self.rawValue)"
}
/// Metal shader function name
var shaderFunctionName: String {
switch self {
case .fftClassic:
return "fftClassicFragment"
case .melSpectrogram:
return "melSpectrogramFragment"
case .subBass:
return "subBassFragment"
case .sidechainPump:
return "sidechainPumpFragment"
case .hnr:
return "hnrFragment"
case .mandelbrot:
return "mandelbrotFragment"
case .tunnelWarp:
return "tunnelWarpFragment"
case .dmtGeometry:
return "dmtGeometryFragment"
}
}
/// Description of the visualization
var description: String {
switch self {
case .fftClassic:
return "Classic frequency spectrum bars with glow effects"
case .melSpectrogram:
return "64-band Mel spectrogram with scrolling waterfall display"
case .subBass:
return "Pulsating rings visualizing sub-bass energy below 100Hz"
case .sidechainPump:
return "Breathing zoom effect synchronized to sidechain pumping"
case .hnr:
return "Harmonic vs noise visualization with geometric shapes"
case .mandelbrot:
return "Audio-reactive Mandelbrot fractal with zoom and color cycling"
case .tunnelWarp:
return "Infinite tunnel effect with warp distortion"
case .dmtGeometry:
return "Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra"
}
}
/// Creates mode from keyboard key code
static func fromKeyCode(_ keyCode: UInt16) -> VisualizationMode? {
// Key codes for 1-8 on US keyboard
let keyCodes: [UInt16: Int] = [
18: 1, // 1
19: 2, // 2
20: 3, // 3
21: 4, // 4
23: 5, // 5
22: 6, // 6
26: 7, // 7
28: 8 // 8
]
guard let modeNumber = keyCodes[keyCode] else { return nil }
return VisualizationMode(rawValue: modeNumber)
}
}
+41
View File
@@ -0,0 +1,41 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PsytranceVisualizer",
platforms: [
.macOS(.v13)
],
products: [
.executable(
name: "PsytranceVisualizer",
targets: ["PsytranceVisualizer"]
)
],
targets: [
.executableTarget(
name: "PsytranceVisualizer",
path: ".",
exclude: [
"Package.swift",
"README.md"
],
sources: [
"App",
"Audio",
"Models",
"Rendering",
"UI",
"Utilities"
],
resources: [
.process("Resources")
],
swiftSettings: [
.unsafeFlags(["-enable-bare-slash-regex"])
]
)
]
)
@@ -0,0 +1,465 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000001 /* PsytranceVisualizerApp.swift */; };
1100000000000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000002 /* AppDelegate.swift */; };
1100000000000003 /* AudioInputManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000003 /* AudioInputManager.swift */; };
1100000000000004 /* DSPEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000004 /* DSPEngine.swift */; };
1100000000000005 /* AudioAnalysisData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000005 /* AudioAnalysisData.swift */; };
1100000000000006 /* VisualizationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000006 /* VisualizationMode.swift */; };
1100000000000007 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000007 /* AppSettings.swift */; };
1100000000000008 /* MetalRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000008 /* MetalRenderer.swift */; };
1100000000000009 /* Common.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000009 /* Common.metal */; };
1100000000000010 /* FFTClassicShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000010 /* FFTClassicShader.metal */; };
1100000000000011 /* MelSpectrogramShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000011 /* MelSpectrogramShader.metal */; };
1100000000000012 /* SubBassShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000012 /* SubBassShader.metal */; };
1100000000000013 /* SidechainPumpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000013 /* SidechainPumpShader.metal */; };
1100000000000014 /* HNRShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000014 /* HNRShader.metal */; };
1100000000000015 /* MandelbrotShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000015 /* MandelbrotShader.metal */; };
1100000000000016 /* TunnelWarpShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000016 /* TunnelWarpShader.metal */; };
1100000000000017 /* DMTGeometryShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000017 /* DMTGeometryShader.metal */; };
1100000000000018 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000018 /* MainWindow.swift */; };
1100000000000019 /* ControlPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000019 /* ControlPanel.swift */; };
1100000000000020 /* VisualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000020 /* VisualizerView.swift */; };
1100000000000021 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000021 /* SettingsManager.swift */; };
1100000000000022 /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2100000000000022 /* ColorPalette.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2000000000000001 /* PsytranceVisualizer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PsytranceVisualizer.app; sourceTree = BUILT_PRODUCTS_DIR; };
2100000000000001 /* PsytranceVisualizerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PsytranceVisualizerApp.swift; sourceTree = "<group>"; };
2100000000000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
2100000000000003 /* AudioInputManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioInputManager.swift; sourceTree = "<group>"; };
2100000000000004 /* DSPEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSPEngine.swift; sourceTree = "<group>"; };
2100000000000005 /* AudioAnalysisData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAnalysisData.swift; sourceTree = "<group>"; };
2100000000000006 /* VisualizationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizationMode.swift; sourceTree = "<group>"; };
2100000000000007 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
2100000000000008 /* MetalRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalRenderer.swift; sourceTree = "<group>"; };
2100000000000009 /* Common.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Common.metal; sourceTree = "<group>"; };
2100000000000010 /* FFTClassicShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = FFTClassicShader.metal; sourceTree = "<group>"; };
2100000000000011 /* MelSpectrogramShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MelSpectrogramShader.metal; sourceTree = "<group>"; };
2100000000000012 /* SubBassShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SubBassShader.metal; sourceTree = "<group>"; };
2100000000000013 /* SidechainPumpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = SidechainPumpShader.metal; sourceTree = "<group>"; };
2100000000000014 /* HNRShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = HNRShader.metal; sourceTree = "<group>"; };
2100000000000015 /* MandelbrotShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = MandelbrotShader.metal; sourceTree = "<group>"; };
2100000000000016 /* TunnelWarpShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = TunnelWarpShader.metal; sourceTree = "<group>"; };
2100000000000017 /* DMTGeometryShader.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = DMTGeometryShader.metal; sourceTree = "<group>"; };
2100000000000018 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
2100000000000019 /* ControlPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlPanel.swift; sourceTree = "<group>"; };
2100000000000020 /* VisualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerView.swift; sourceTree = "<group>"; };
2100000000000021 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
2100000000000022 /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = "<group>"; };
2100000000000023 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
2100000000000024 /* PsytranceVisualizer.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PsytranceVisualizer.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
3000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
4000000000000001 = {
isa = PBXGroup;
children = (
4000000000000002 /* App */,
4000000000000003 /* Audio */,
4000000000000004 /* Models */,
4000000000000005 /* Rendering */,
4000000000000007 /* UI */,
4000000000000008 /* Utilities */,
4000000000000009 /* Resources */,
4000000000000010 /* Products */,
);
sourceTree = "<group>";
};
4000000000000002 /* App */ = {
isa = PBXGroup;
children = (
2100000000000001 /* PsytranceVisualizerApp.swift */,
2100000000000002 /* AppDelegate.swift */,
);
path = App;
sourceTree = "<group>";
};
4000000000000003 /* Audio */ = {
isa = PBXGroup;
children = (
2100000000000003 /* AudioInputManager.swift */,
2100000000000004 /* DSPEngine.swift */,
);
path = Audio;
sourceTree = "<group>";
};
4000000000000004 /* Models */ = {
isa = PBXGroup;
children = (
2100000000000005 /* AudioAnalysisData.swift */,
2100000000000006 /* VisualizationMode.swift */,
2100000000000007 /* AppSettings.swift */,
);
path = Models;
sourceTree = "<group>";
};
4000000000000005 /* Rendering */ = {
isa = PBXGroup;
children = (
2100000000000008 /* MetalRenderer.swift */,
4000000000000006 /* Shaders */,
);
path = Rendering;
sourceTree = "<group>";
};
4000000000000006 /* Shaders */ = {
isa = PBXGroup;
children = (
2100000000000009 /* Common.metal */,
2100000000000010 /* FFTClassicShader.metal */,
2100000000000011 /* MelSpectrogramShader.metal */,
2100000000000012 /* SubBassShader.metal */,
2100000000000013 /* SidechainPumpShader.metal */,
2100000000000014 /* HNRShader.metal */,
2100000000000015 /* MandelbrotShader.metal */,
2100000000000016 /* TunnelWarpShader.metal */,
2100000000000017 /* DMTGeometryShader.metal */,
);
path = Shaders;
sourceTree = "<group>";
};
4000000000000007 /* UI */ = {
isa = PBXGroup;
children = (
2100000000000018 /* MainWindow.swift */,
2100000000000019 /* ControlPanel.swift */,
2100000000000020 /* VisualizerView.swift */,
);
path = UI;
sourceTree = "<group>";
};
4000000000000008 /* Utilities */ = {
isa = PBXGroup;
children = (
2100000000000021 /* SettingsManager.swift */,
2100000000000022 /* ColorPalette.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
4000000000000009 /* Resources */ = {
isa = PBXGroup;
children = (
2100000000000023 /* Info.plist */,
2100000000000024 /* PsytranceVisualizer.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
4000000000000010 /* Products */ = {
isa = PBXGroup;
children = (
2000000000000001 /* PsytranceVisualizer.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5000000000000001 /* PsytranceVisualizer */ = {
isa = PBXNativeTarget;
buildConfigurationList = 6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */;
buildPhases = (
5000000000000002 /* Sources */,
3000000000000001 /* Frameworks */,
5000000000000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PsytranceVisualizer;
productName = PsytranceVisualizer;
productReference = 2000000000000001 /* PsytranceVisualizer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
5000000000000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 4000000000000001;
productRefGroup = 4000000000000010 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
5000000000000001 /* PsytranceVisualizer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
5000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1100000000000001 /* PsytranceVisualizerApp.swift in Sources */,
1100000000000002 /* AppDelegate.swift in Sources */,
1100000000000003 /* AudioInputManager.swift in Sources */,
1100000000000004 /* DSPEngine.swift in Sources */,
1100000000000005 /* AudioAnalysisData.swift in Sources */,
1100000000000006 /* VisualizationMode.swift in Sources */,
1100000000000007 /* AppSettings.swift in Sources */,
1100000000000008 /* MetalRenderer.swift in Sources */,
1100000000000009 /* Common.metal in Sources */,
1100000000000010 /* FFTClassicShader.metal in Sources */,
1100000000000011 /* MelSpectrogramShader.metal in Sources */,
1100000000000012 /* SubBassShader.metal in Sources */,
1100000000000013 /* SidechainPumpShader.metal in Sources */,
1100000000000014 /* HNRShader.metal in Sources */,
1100000000000015 /* MandelbrotShader.metal in Sources */,
1100000000000016 /* TunnelWarpShader.metal in Sources */,
1100000000000017 /* DMTGeometryShader.metal in Sources */,
1100000000000018 /* MainWindow.swift in Sources */,
1100000000000019 /* ControlPanel.swift in Sources */,
1100000000000020 /* VisualizerView.swift in Sources */,
1100000000000021 /* SettingsManager.swift in Sources */,
1100000000000022 /* ColorPalette.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
6100000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
6100000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
6100000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
6100000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Resources/PsytranceVisualizer.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Psytrance Visualizer needs access to your audio input to visualize music in real-time.";
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psytrance.visualizer;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6000000000000001 /* Build configuration list for PBXProject "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000001 /* Debug */,
6100000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
6000000000000003 /* Build configuration list for PBXNativeTarget "PsytranceVisualizer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6100000000000003 /* Debug */,
6100000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 0000000000000001 /* Project object */;
}
@@ -0,0 +1,279 @@
//
// MetalRenderer.swift
// PsytranceVisualizer
//
// Metal-based renderer for all visualization modes
//
import MetalKit
import simd
/// Uniform data passed to all shaders
struct ShaderUniforms {
var time: Float
var resolution: SIMD2<Float>
var reactivity: Float
// Audio analysis data
var subBassEnergy: Float
var sidechainPump: Float
var sidechainEnvelope: Float
var hnrRatio: Float
var isPeak: Float
var peakIntensity: Float
var spectralCentroid: Float
var rmsLevel: Float
// Visualization mode (1-8)
var mode: Int32
// Padding for Metal alignment
var padding: SIMD2<Float> = .zero
}
/// Metal renderer managing all visualization shaders
final class MetalRenderer: NSObject, ObservableObject {
// MARK: - Properties
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private var pipelineStates: [VisualizationMode: MTLRenderPipelineState] = [:]
private var currentPipelineState: MTLRenderPipelineState?
@Published private(set) var currentMode: VisualizationMode = .fftClassic
// MARK: - Buffers
private var uniformBuffer: MTLBuffer?
private var fftBuffer: MTLBuffer?
private var melBuffer: MTLBuffer?
private var subBassHistoryBuffer: MTLBuffer?
// MARK: - State
private var startTime: CFAbsoluteTime
private var uniforms = ShaderUniforms(
time: 0,
resolution: SIMD2<Float>(1920, 1080),
reactivity: 0.5,
subBassEnergy: 0,
sidechainPump: 0,
sidechainEnvelope: 0,
hnrRatio: 0.5,
isPeak: 0,
peakIntensity: 0,
spectralCentroid: 0.5,
rmsLevel: 0,
mode: 1
)
private var audioData: AudioAnalysisData = .empty
// MARK: - Constants
private let maxFFTSize = 1024
private let melBandCount = 64
private let historySize = 128
// MARK: - Initialization
init?(device: MTLDevice) {
guard let queue = device.makeCommandQueue() else {
print("[MetalRenderer] Failed to create command queue")
return nil
}
self.device = device
self.commandQueue = queue
self.startTime = CFAbsoluteTimeGetCurrent()
super.init()
createBuffers()
loadShaders()
}
// MARK: - Public Methods
/// Sets the current visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
currentMode = mode
currentPipelineState = pipelineStates[mode]
uniforms.mode = Int32(mode.rawValue)
print("[MetalRenderer] Mode changed to: \(mode.displayName)")
}
/// Updates audio analysis data
func updateAudioData(_ data: AudioAnalysisData) {
audioData = data
// Update uniforms
uniforms.subBassEnergy = data.subBassEnergy
uniforms.sidechainPump = data.sidechainPumpAmount
uniforms.sidechainEnvelope = data.sidechainEnvelope
uniforms.hnrRatio = data.hnrRatio
uniforms.isPeak = data.isPeak ? 1.0 : 0.0
uniforms.peakIntensity = data.peakIntensity
uniforms.spectralCentroid = data.spectralCentroid
uniforms.rmsLevel = data.rmsLevel
// Update FFT buffer
updateFFTBuffer(data.fftMagnitudes)
// Update Mel buffer
updateMelBuffer(data.melBands)
// Update sub-bass history buffer
updateSubBassHistoryBuffer(data.subBassHistory)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
uniforms.reactivity = max(0.0, min(1.0, value))
}
// MARK: - Private Methods
private func createBuffers() {
// Uniform buffer
uniformBuffer = device.makeBuffer(
length: MemoryLayout<ShaderUniforms>.stride,
options: .storageModeShared
)
// FFT magnitude buffer
fftBuffer = device.makeBuffer(
length: maxFFTSize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Mel bands buffer
melBuffer = device.makeBuffer(
length: melBandCount * MemoryLayout<Float>.stride,
options: .storageModeShared
)
// Sub-bass history buffer
subBassHistoryBuffer = device.makeBuffer(
length: historySize * MemoryLayout<Float>.stride,
options: .storageModeShared
)
}
private func updateFFTBuffer(_ magnitudes: [Float]) {
guard let buffer = fftBuffer else { return }
let count = min(magnitudes.count, maxFFTSize)
memcpy(buffer.contents(), magnitudes, count * MemoryLayout<Float>.stride)
}
private func updateMelBuffer(_ bands: [Float]) {
guard let buffer = melBuffer else { return }
let count = min(bands.count, melBandCount)
memcpy(buffer.contents(), bands, count * MemoryLayout<Float>.stride)
}
private func updateSubBassHistoryBuffer(_ history: [Float]) {
guard let buffer = subBassHistoryBuffer else { return }
let count = min(history.count, historySize)
memcpy(buffer.contents(), history, count * MemoryLayout<Float>.stride)
}
private func loadShaders() {
guard let library = device.makeDefaultLibrary() else {
print("[MetalRenderer] Failed to load shader library")
return
}
// Load vertex shader (shared)
guard let vertexFunction = library.makeFunction(name: "vertexShader") else {
print("[MetalRenderer] Failed to load vertex shader")
return
}
// Load all fragment shaders
for mode in VisualizationMode.allCases {
guard let fragmentFunction = library.makeFunction(name: mode.shaderFunctionName) else {
print("[MetalRenderer] Failed to load shader: \(mode.shaderFunctionName)")
continue
}
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = vertexFunction
descriptor.fragmentFunction = fragmentFunction
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
// Enable blending for glow effects
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one
descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
do {
let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
pipelineStates[mode] = pipelineState
print("[MetalRenderer] Loaded shader: \(mode.displayName)")
} catch {
print("[MetalRenderer] Failed to create pipeline state for \(mode.displayName): \(error)")
}
}
// Set initial pipeline state
currentPipelineState = pipelineStates[.fftClassic]
}
}
// MARK: - MTKViewDelegate
extension MetalRenderer: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
uniforms.resolution = SIMD2<Float>(Float(size.width), Float(size.height))
}
func draw(in view: MTKView) {
guard let pipelineState = currentPipelineState,
let drawable = view.currentDrawable,
let renderPassDescriptor = view.currentRenderPassDescriptor else {
return
}
// Update time
uniforms.time = Float(CFAbsoluteTimeGetCurrent() - startTime)
// Update uniform buffer
if let buffer = uniformBuffer {
memcpy(buffer.contents(), &uniforms, MemoryLayout<ShaderUniforms>.stride)
}
// Create command buffer
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
return
}
// Set pipeline state
renderEncoder.setRenderPipelineState(pipelineState)
// Set buffers
if let buffer = uniformBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 0)
}
if let buffer = fftBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 1)
}
if let buffer = melBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 2)
}
if let buffer = subBassHistoryBuffer {
renderEncoder.setFragmentBuffer(buffer, offset: 0, index: 3)
}
// Draw fullscreen quad
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
@@ -0,0 +1,241 @@
//
// Common.metal
// PsytranceVisualizer
//
// Shared shader functions, types, and psytrance color palette
//
#include <metal_stdlib>
using namespace metal;
// MARK: - Uniforms Structure
struct ShaderUniforms {
float time;
float2 resolution;
float reactivity;
float subBassEnergy;
float sidechainPump;
float sidechainEnvelope;
float hnrRatio;
float isPeak;
float peakIntensity;
float spectralCentroid;
float rmsLevel;
int mode;
float2 padding;
};
// MARK: - Vertex Data
struct VertexOut {
float4 position [[position]];
float2 uv;
};
// MARK: - Psytrance Color Palette
constant float3 neonMagenta = float3(1.0, 0.0, 1.0);
constant float3 neonCyan = float3(0.0, 1.0, 1.0);
constant float3 neonGreen = float3(0.224, 1.0, 0.078);
constant float3 uvViolet = float3(0.482, 0.0, 1.0);
constant float3 hotPink = float3(1.0, 0.2, 0.6);
constant float3 electricBlue = float3(0.0, 0.5, 1.0);
constant float3 deepPurple = float3(0.1, 0.0, 0.15);
// MARK: - Palette Functions
inline float3 getPaletteColor(int index) {
switch (index % 6) {
case 0: return neonMagenta;
case 1: return neonCyan;
case 2: return neonGreen;
case 3: return uvViolet;
case 4: return hotPink;
default: return electricBlue;
}
}
inline float3 rainbowPalette(float t) {
float3 a = float3(0.5, 0.5, 0.5);
float3 b = float3(0.5, 0.5, 0.5);
float3 c = float3(1.0, 1.0, 1.0);
float3 d = float3(0.0, 0.33, 0.67);
return a + b * cos(6.28318 * (c * t + d));
}
inline float3 psytrancePalette(float t, float time) {
// Cycle through psytrance colors
float phase = fract(t + time * 0.1);
if (phase < 0.2) {
return mix(uvViolet, neonMagenta, phase * 5.0);
} else if (phase < 0.4) {
return mix(neonMagenta, hotPink, (phase - 0.2) * 5.0);
} else if (phase < 0.6) {
return mix(hotPink, neonCyan, (phase - 0.4) * 5.0);
} else if (phase < 0.8) {
return mix(neonCyan, neonGreen, (phase - 0.6) * 5.0);
} else {
return mix(neonGreen, uvViolet, (phase - 0.8) * 5.0);
}
}
// MARK: - Heatmap for Spectrogram
inline float3 heatmap(float t) {
// Low energy: dark purple
// High energy: white through neon colors
if (t < 0.2) {
return mix(float3(0.05, 0.0, 0.1), uvViolet, t * 5.0);
} else if (t < 0.4) {
return mix(uvViolet, neonMagenta, (t - 0.2) * 5.0);
} else if (t < 0.6) {
return mix(neonMagenta, hotPink, (t - 0.4) * 5.0);
} else if (t < 0.8) {
return mix(hotPink, neonCyan, (t - 0.6) * 5.0);
} else {
return mix(neonCyan, float3(1.0), (t - 0.8) * 5.0);
}
}
// MARK: - Noise Functions
// Simplex-like noise
inline float hash(float2 p) {
float3 p3 = fract(float3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
inline float noise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + float2(1.0, 0.0));
float c = hash(i + float2(0.0, 1.0));
float d = hash(i + float2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
inline float fbm(float2 p, int octaves) {
float value = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int i = 0; i < octaves; i++) {
value += amplitude * noise(p * frequency);
frequency *= 2.0;
amplitude *= 0.5;
}
return value;
}
// 3D noise for volumetric effects
inline float noise3D(float3 p) {
float3 i = floor(p);
float3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float2 uv = i.xy + float2(37.0, 17.0) * i.z;
float a = hash(uv);
float b = hash(uv + float2(1.0, 0.0));
float c = hash(uv + float2(0.0, 1.0));
float d = hash(uv + float2(1.0, 1.0));
float2 uv2 = uv + float2(37.0, 17.0);
float e = hash(uv2);
float ff = hash(uv2 + float2(1.0, 0.0));
float g = hash(uv2 + float2(0.0, 1.0));
float h = hash(uv2 + float2(1.0, 1.0));
float x1 = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float x2 = mix(mix(e, ff, f.x), mix(g, h, f.x), f.y);
return mix(x1, x2, f.z);
}
// MARK: - Utility Functions
inline float2 rotate(float2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
inline float map(float value, float inMin, float inMax, float outMin, float outMax) {
return outMin + (outMax - outMin) * (value - inMin) / (inMax - inMin);
}
inline float smoothstepEdge(float edge0, float edge1, float x) {
float t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
// MARK: - Glow Effect
inline float3 addGlow(float3 color, float intensity, float3 glowColor) {
return color + glowColor * intensity * intensity;
}
// MARK: - SDF Functions for Geometry
inline float sdCircle(float2 p, float r) {
return length(p) - r;
}
inline float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
inline float sdHexagon(float2 p, float r) {
const float3 k = float3(-0.866025404, 0.5, 0.577350269);
p = abs(p);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= float2(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
inline float sdStar(float2 p, float r, int n, float m) {
float an = 3.141593 / float(n);
float en = 3.141593 / m;
float2 acs = float2(cos(an), sin(an));
float2 ecs = float2(cos(en), sin(en));
float bn = fmod(atan2(p.x, p.y), 2.0 * an) - an;
p = length(p) * float2(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
return length(p) * sign(p.x);
}
// MARK: - Vertex Shader (Fullscreen Quad)
vertex VertexOut vertexShader(uint vertexID [[vertex_id]]) {
// Generate fullscreen quad
float2 positions[4] = {
float2(-1.0, -1.0),
float2( 1.0, -1.0),
float2(-1.0, 1.0),
float2( 1.0, 1.0)
};
float2 uvs[4] = {
float2(0.0, 1.0),
float2(1.0, 1.0),
float2(0.0, 0.0),
float2(1.0, 0.0)
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
out.uv = uvs[vertexID];
return out;
}
@@ -0,0 +1,290 @@
//
// DMTGeometryShader.metal
// PsytranceVisualizer
//
// Sacred geometry patterns: Flower of Life, Metatron's Cube, Sri Yantra, Hexagonal
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
// === SACRED GEOMETRY PRIMITIVES ===
// Flower of Life - overlapping circles
float flowerOfLife(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float circleRadius = 0.5;
// Center circle
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, length(p)));
// 6 circles around center
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius;
float d = length(p - offset);
result = max(result, 1.0 - smoothstep(circleRadius - 0.02, circleRadius, d));
}
// Second ring of 12 circles
for (int i = 0; i < 12; i++) {
float angle = float(i) * 3.14159 / 6.0 + time * 0.05;
float2 offset = float2(cos(angle), sin(angle)) * circleRadius * 2.0;
float d = length(p - offset);
result = max(result, 0.5 * (1.0 - smoothstep(circleRadius - 0.02, circleRadius, d)));
}
return result;
}
// Metatron's Cube - 13 circles with connecting lines
float metatronsCube(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float nodeRadius = 0.08;
float lineWidth = 0.01;
// Define the 13 points of Metatron's Cube
float2 points[13];
points[0] = float2(0.0, 0.0); // Center
// Inner hexagon
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + time * 0.1;
points[i + 1] = float2(cos(angle), sin(angle)) * 0.5;
}
// Outer hexagon (rotated)
for (int i = 0; i < 6; i++) {
float angle = float(i) * 3.14159 / 3.0 + 3.14159 / 6.0 + time * 0.1;
points[i + 7] = float2(cos(angle), sin(angle)) * 0.866;
}
// Draw nodes
for (int i = 0; i < 13; i++) {
float d = length(p - points[i]);
float node = 1.0 - smoothstep(nodeRadius - 0.01, nodeRadius, d);
result = max(result, node);
}
// Draw connecting lines
for (int i = 0; i < 13; i++) {
for (int j = i + 1; j < 13; j++) {
float2 a = points[i];
float2 b = points[j];
float2 pa = p - a;
float2 ba = b - a;
float t = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float d = length(pa - ba * t);
float line = 1.0 - smoothstep(lineWidth, lineWidth + 0.005, d);
result = max(result, line * 0.5);
}
}
return result;
}
// Sri Yantra - 9 interlocking triangles
float sriYantra(float2 p, float scale, float time) {
p *= scale;
float result = 0.0;
float lineWidth = 0.015;
// Rotating factor
float rot = time * 0.05;
// Draw 4 upward triangles
for (int i = 0; i < 4; i++) {
float size = 0.3 + float(i) * 0.15;
float yOffset = -0.1 + float(i) * 0.05;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, rot);
// Triangle SDF
float2 a = float2(0.0, size);
float2 b = float2(-size * 0.866, -size * 0.5);
float2 c = float2(size * 0.866, -size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.15));
}
// Draw 5 downward triangles
for (int i = 0; i < 5; i++) {
float size = 0.25 + float(i) * 0.12;
float yOffset = 0.1 - float(i) * 0.04;
float2 tp = p - float2(0.0, yOffset);
tp = rotate(tp, -rot);
float2 a = float2(0.0, -size);
float2 b = float2(-size * 0.866, size * 0.5);
float2 c = float2(size * 0.866, size * 0.5);
float d1 = dot(tp - a, normalize(float2(b.y - a.y, a.x - b.x)));
float d2 = dot(tp - b, normalize(float2(c.y - b.y, b.x - c.x)));
float d3 = dot(tp - c, normalize(float2(a.y - c.y, c.x - a.x)));
float triangleDist = max(max(d1, d2), d3);
float edge = 1.0 - smoothstep(0.0, lineWidth, abs(triangleDist));
result = max(result, edge * (1.0 - float(i) * 0.12));
}
// Central bindu (point)
float bindu = 1.0 - smoothstep(0.03, 0.04, length(p));
result = max(result, bindu);
return result;
}
// Hexagonal grid pattern
float hexagonalPattern(float2 p, float scale, float time) {
p *= scale;
// Hexagonal grid transformation
float2 s = float2(1.0, 1.732);
float2 h = s * 0.5;
float2 a = fmod(p, s) - h;
float2 b = fmod(p + h, s) - h;
float2 gv = dot(a, a) < dot(b, b) ? a : b;
float hexDist = max(abs(gv.x), dot(abs(gv), normalize(float2(1.0, 1.732))));
float edge = 1.0 - smoothstep(0.4, 0.42, hexDist);
float fill = smoothstep(0.38, 0.4, hexDist);
// Animate individual hexagons
float2 cellId = floor(p / s);
float cellPhase = hash(cellId + floor(time * 0.5)) * 2.0 * 3.14159;
float pulse = 0.5 + 0.5 * sin(time * 3.0 + cellPhase);
return edge + fill * pulse * 0.3;
}
// === MAIN FRAGMENT SHADER ===
fragment float4 dmtGeometryFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float hnr = uniforms.hnrRatio;
float peak = uniforms.isPeak;
float peakIntensity = uniforms.peakIntensity;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Scale pulsing with sub-bass
float scale = 2.0 + subBass * 0.5 * (0.5 + reactivity * 0.5);
p *= scale;
// Rotation
float rotation = time * 0.1;
p = rotate(p, rotation);
// Determine which geometry to show
// Changes on peaks or every few seconds
float cycleTime = 8.0; // Seconds per geometry
float cyclePhase = fmod(time, cycleTime * 4.0) / cycleTime;
int geometryIndex = int(cyclePhase);
// Force change on strong peaks
if (peak > 0.5 && peakIntensity > 0.7) {
geometryIndex = int(fmod(float(geometryIndex) + 1.0, 4.0));
}
// Calculate all geometries (for blending)
float flower = flowerOfLife(p, 1.0, time);
float metatron = metatronsCube(p, 1.5, time);
float yantra = sriYantra(p, 1.2, time);
float hexGrid = hexagonalPattern(p, 3.0, time);
// Select primary and secondary for blending
float primary = 0.0;
float secondary = 0.0;
float blendPhase = fract(cyclePhase);
switch (geometryIndex) {
case 0:
primary = flower;
secondary = metatron;
break;
case 1:
primary = metatron;
secondary = yantra;
break;
case 2:
primary = yantra;
secondary = hexGrid;
break;
default:
primary = hexGrid;
secondary = flower;
break;
}
// Smooth transition
float transitionWindow = 0.2; // 20% of cycle for transition
float blend = smoothstep(1.0 - transitionWindow, 1.0, blendPhase);
float geometry = mix(primary, secondary, blend);
// Complexity based on HNR (more harmonic = more detail)
geometry *= 0.7 + hnr * 0.3;
// Color based on geometry and audio
float colorPhase = time * 0.1 + geometry * 0.5;
float3 geometryColor = psytrancePalette(colorPhase, time);
// Glow intensity from peak
float glowIntensity = 0.5 + peakIntensity * 0.5;
float3 glowColor = mix(neonMagenta, neonCyan, 0.5 + 0.5 * sin(time));
// Compose final color
float3 finalColor = geometryColor * geometry;
// Add glow
finalColor = addGlow(finalColor, geometry * glowIntensity, glowColor);
// Background - subtle pulsing gradient
float dist = length(uv - 0.5);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
bgColor *= 0.8 + 0.2 * subBass;
finalColor = mix(bgColor, finalColor, clamp(geometry * 1.5, 0.0, 1.0));
// Peak flash
if (peak > 0.5) {
finalColor += float3(1.0) * peakIntensity * 0.2;
}
// Outer glow
float outerGlow = exp(-dist * 3.0);
finalColor += neonMagenta * outerGlow * 0.1 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,117 @@
//
// FFTClassicShader.metal
// PsytranceVisualizer
//
// Classic FFT bar visualization with glow effects
//
#include <metal_stdlib>
using namespace metal;
// Include common definitions
#include "Common.metal"
fragment float4 fftClassicFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Number of bars to display
const int numBars = 64;
const float barWidth = 1.0 / float(numBars);
const float barGap = barWidth * 0.2;
const float actualBarWidth = barWidth - barGap;
// Determine which bar this pixel belongs to
int barIndex = int(uv.x * float(numBars));
barIndex = clamp(barIndex, 0, numBars - 1);
// Get FFT magnitude for this bar (with some averaging for smoothness)
float magnitude = fftData[barIndex];
// Apply reactivity scaling
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Calculate bar position within its cell
float barCellX = fract(uv.x * float(numBars));
float barCenterX = 0.5;
// Distance from bar center (for width calculation)
float distFromCenter = abs(barCellX - barCenterX);
float halfWidth = actualBarWidth * 0.5 / barWidth;
// Check if we're inside the bar horizontally
bool insideBarX = distFromCenter < halfWidth;
// Bar height from bottom
float barHeight = magnitude;
// Add some bounce on peaks
if (uniforms.isPeak > 0.5) {
barHeight += uniforms.peakIntensity * 0.1 * sin(time * 20.0 + float(barIndex) * 0.3);
}
// Check if we're inside the bar vertically (from bottom)
float yFromBottom = 1.0 - uv.y;
bool insideBarY = yFromBottom < barHeight;
// Color based on frequency and magnitude
float colorPhase = float(barIndex) / float(numBars) + time * 0.05;
float3 barColor = psytrancePalette(colorPhase, time);
// Intensity gradient from bottom to top
float intensityGradient = yFromBottom / max(barHeight, 0.01);
intensityGradient = clamp(intensityGradient, 0.0, 1.0);
// Make top of bars brighter
barColor = mix(barColor * 0.6, barColor * 1.5, intensityGradient);
// Calculate glow
float glowRadius = 0.05 * (1.0 + magnitude);
float distToBar = 0.0;
if (!insideBarX) {
distToBar = (distFromCenter - halfWidth) * barWidth;
}
if (!insideBarY && yFromBottom >= barHeight) {
float vertDist = yFromBottom - barHeight;
distToBar = max(distToBar, vertDist);
}
float glow = exp(-distToBar * distToBar / (glowRadius * glowRadius * 2.0));
glow *= magnitude;
// Final color
float3 finalColor = float3(0.0);
if (insideBarX && insideBarY) {
// Inside the bar
finalColor = barColor;
// Add peak cap (bright line at top)
float capThickness = 0.01;
if (abs(yFromBottom - barHeight) < capThickness) {
finalColor = float3(1.0); // White cap
}
} else {
// Add glow outside bars
finalColor = barColor * glow * 0.5;
}
// Add subtle background pulse with sub-bass
float bgPulse = uniforms.subBassEnergy * 0.05;
finalColor += deepPurple * bgPulse;
// Add overall glow at peaks
if (uniforms.isPeak > 0.5) {
finalColor += neonMagenta * uniforms.peakIntensity * 0.1;
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,142 @@
//
// HNRShader.metal
// PsytranceVisualizer
//
// Harmonic-to-Noise ratio visualization with geometric shapes vs chaos
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 hnrFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float hnr = uniforms.hnrRatio;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// === HARMONIC SIDE (High HNR = Clear geometric shapes) ===
// Rotating hexagon
float2 rotP = rotate(p, time * 0.5);
float hexDist = sdHexagon(rotP, 0.2 + subBass * 0.1);
float hexEdge = 1.0 - smoothstep(0.0, 0.02, abs(hexDist));
// Inner rotating triangle (star)
float2 rotP2 = rotate(p, -time * 0.3);
float starDist = sdStar(rotP2, 0.12 + subBass * 0.05, 3, 2.5);
float starEdge = 1.0 - smoothstep(0.0, 0.015, abs(starDist));
// Concentric circles
float circles = 0.0;
for (int i = 0; i < 4; i++) {
float radius = 0.1 + float(i) * 0.08 + sin(time + float(i)) * 0.02;
float circleDist = abs(dist - radius);
float circle = 1.0 - smoothstep(0.0, 0.008, circleDist);
circles += circle;
}
// Combine harmonic shapes
float harmonicShapes = hexEdge + starEdge * 0.8 + circles * 0.5;
harmonicShapes = clamp(harmonicShapes, 0.0, 1.0);
// Harmonic color - clean neon
float3 harmonicColor = mix(neonCyan, neonMagenta, 0.5 + 0.5 * sin(angle * 2.0 + time));
// === NOISE SIDE (Low HNR = Chaotic particles) ===
// Noise-based particles
float noiseField = 0.0;
for (int i = 0; i < 5; i++) {
float2 noiseP = p * (3.0 + float(i) * 2.0);
noiseP += time * float(i + 1) * 0.1;
float n = noise(noiseP);
n = pow(n, 2.0);
noiseField += n * (1.0 / float(i + 1));
}
noiseField = clamp(noiseField, 0.0, 1.0);
// Turbulent swirls
float2 turbP = p * 4.0;
float turbulence = fbm(turbP + time * 0.5, 4);
// Chaotic speckles
float speckles = 0.0;
for (int i = 0; i < 30; i++) {
float2 specklePos = float2(
hash(float2(float(i) * 0.1, time * 0.01)) - 0.5,
hash(float2(float(i) * 0.2, time * 0.01 + 0.5)) - 0.5
);
specklePos *= 0.8;
specklePos.x *= aspectRatio;
float speckleDist = length(p - specklePos);
float speckle = exp(-speckleDist * speckleDist * 500.0);
speckle *= hash(float2(float(i), floor(time * 2.0)));
speckles += speckle;
}
float noiseVisual = noiseField * 0.4 + turbulence * 0.3 + speckles * 0.3;
noiseVisual = clamp(noiseVisual, 0.0, 1.0);
// Noise color - harsh, flickering
float3 noiseColor = mix(hotPink, uvViolet, turbulence);
noiseColor *= 0.8 + 0.2 * sin(time * 20.0 + noise(p * 10.0) * 10.0);
// === BLEND based on HNR ===
// HNR determines the mix: 1.0 = pure harmonic, 0.0 = pure noise
float harmonicAmount = hnr;
float noiseAmount = 1.0 - hnr;
// Apply reactivity to make transition more dramatic
harmonicAmount = pow(harmonicAmount, 1.0 / (1.0 + reactivity));
float3 harmonicContrib = harmonicColor * harmonicShapes * harmonicAmount;
float3 noiseContrib = noiseColor * noiseVisual * noiseAmount;
float3 finalColor = harmonicContrib + noiseContrib;
// Add center indicator showing current HNR
float indicator = smoothstep(0.25, 0.24, dist) - smoothstep(0.24, 0.23, dist);
float indicatorFill = smoothstep(0.23, 0.22, dist);
// Split indicator by HNR
float harmonicSide = step(0.0, p.x);
float noiseSide = 1.0 - harmonicSide;
finalColor += neonCyan * indicator * 0.3;
finalColor += neonCyan * indicatorFill * harmonicSide * hnr * 0.2;
finalColor += hotPink * indicatorFill * noiseSide * (1.0 - hnr) * 0.2;
// Background glow
float bgGlow = exp(-dist * dist * 4.0);
float3 bgColor = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgColor * (1.0 - clamp(harmonicShapes + noiseVisual, 0.0, 1.0));
// Peak flash
if (uniforms.isPeak > 0.5) {
finalColor += float3(1.0) * uniforms.peakIntensity * 0.15 * exp(-dist * 3.0);
}
return float4(finalColor, 1.0);
}
@@ -0,0 +1,121 @@
//
// MandelbrotShader.metal
// PsytranceVisualizer
//
// Audio-reactive Mandelbrot fractal with zoom and color cycling
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 mandelbrotFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float centroid = uniforms.spectralCentroid;
// Aspect ratio correction
float aspectRatio = resolution.x / resolution.y;
// Map UV to complex plane
float2 c = (uv - 0.5) * 2.0;
c.x *= aspectRatio;
// Audio-reactive zoom level
// Base zoom increases over time, modulated by sub-bass
float baseZoom = 1.0 + time * 0.02;
float audioZoom = subBass * 0.5 * (0.5 + reactivity * 0.5);
float zoom = pow(2.0, baseZoom + audioZoom);
// Zoom center - drifts based on sidechain
float2 zoomCenter = float2(-0.7, 0.0);
zoomCenter.x += sin(time * 0.1) * 0.3 + pump * 0.1 * sin(time);
zoomCenter.y += cos(time * 0.13) * 0.2 + pump * 0.1 * cos(time);
// Apply zoom
c = c / zoom + zoomCenter;
// Mandelbrot iteration
float2 z = float2(0.0);
int maxIterations = int(50.0 + reactivity * 100.0);
int iterations = 0;
float smoothIter = 0.0;
for (int i = 0; i < 150; i++) {
if (i >= maxIterations) break;
// z = z^2 + c
float2 zNew = float2(
z.x * z.x - z.y * z.y + c.x,
2.0 * z.x * z.y + c.y
);
z = zNew;
float mag2 = dot(z, z);
if (mag2 > 256.0) {
// Smooth iteration count
smoothIter = float(i) - log2(log2(mag2)) + 4.0;
break;
}
iterations = i;
}
// Normalize iteration count
float normalizedIter = smoothIter / float(maxIterations);
// Color based on iterations
float3 color;
if (iterations >= maxIterations - 1) {
// Inside the set - deep color
color = deepPurple * (0.5 + 0.5 * subBass);
} else {
// Outside - color cycling based on iterations and audio
float colorPhase = normalizedIter + time * 0.1 + centroid;
// Use psytrance palette with color rotation
color = psytrancePalette(colorPhase, time);
// Modulate brightness by iteration depth
float brightness = 0.5 + 0.5 * sin(smoothIter * 0.3);
color *= brightness;
// Add glow at boundary
float edgeFactor = 1.0 - normalizedIter;
edgeFactor = pow(edgeFactor, 3.0);
color = addGlow(color, edgeFactor * 0.5, neonCyan);
}
// Sub-bass pulse effect
color *= 0.8 + 0.2 * subBass;
// Sidechain breathing
float breathe = 1.0 + pump * 0.1;
color *= breathe;
// Peak flash in bright areas
if (uniforms.isPeak > 0.5 && iterations < maxIterations - 1) {
color += neonMagenta * uniforms.peakIntensity * 0.2 * normalizedIter;
}
// Subtle vignette
float2 vignetteuv = uv - 0.5;
float vignette = 1.0 - dot(vignetteuv, vignetteuv) * 0.5;
color *= vignette;
return float4(color, 1.0);
}
@@ -0,0 +1,95 @@
//
// MelSpectrogramShader.metal
// PsytranceVisualizer
//
// Mel spectrogram with scrolling waterfall display
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 melSpectrogramFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
// Configuration
const int numBands = 64;
const int historyLength = 128;
// Map UV to mel band and history position
int bandIndex = int(uv.x * float(numBands));
bandIndex = clamp(bandIndex, 0, numBands - 1);
// Scrolling effect - newer data at bottom
float scrollOffset = fract(time * 0.5); // Scroll speed
float yPos = fract(uv.y + scrollOffset);
// Get mel magnitude
float magnitude = melData[bandIndex];
magnitude = magnitude * (0.5 + reactivity * 1.5);
magnitude = clamp(magnitude, 0.0, 1.0);
// Create waterfall effect using history
int historyIndex = int(yPos * float(historyLength));
historyIndex = clamp(historyIndex, 0, historyLength - 1);
// Combine current and historical data for waterfall
float historicalValue = historyData[historyIndex];
// Blend between current magnitude and position-based intensity
float intensity = magnitude;
// Add some variance based on band position
float bandPhase = float(bandIndex) / float(numBands);
intensity *= 0.8 + 0.2 * sin(bandPhase * 6.28318 + time);
// Apply fade for older data (top of screen)
float ageFade = 1.0 - uv.y * 0.3;
intensity *= ageFade;
// Generate color using heatmap
float3 color = heatmap(intensity);
// Add frequency-dependent hue shift
float hueShift = bandPhase * 0.3;
color = psytrancePalette(intensity + hueShift, time);
// Modulate by actual intensity
color *= 0.3 + intensity * 0.7;
// Add grid lines for visual reference
float gridX = abs(fract(uv.x * float(numBands)) - 0.5) * 2.0;
float gridY = abs(fract(uv.y * 16.0) - 0.5) * 2.0;
float gridLine = smoothstep(0.95, 1.0, gridX) + smoothstep(0.95, 1.0, gridY);
gridLine *= 0.1;
color += float3(gridLine) * uvViolet;
// Add glow on high energy
if (intensity > 0.7) {
float glow = (intensity - 0.7) / 0.3;
color = addGlow(color, glow * 0.5, neonCyan);
}
// Peak flash
if (uniforms.isPeak > 0.5) {
color += neonMagenta * uniforms.peakIntensity * 0.15;
}
// Sub-bass emphasis on lower bands
if (bandIndex < 8) {
color += uvViolet * uniforms.subBassEnergy * 0.3;
}
return float4(color, 1.0);
}
@@ -0,0 +1,130 @@
//
// SidechainPumpShader.metal
// PsytranceVisualizer
//
// Visualizes sidechain pumping with breathing zoom effect
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 sidechainPumpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float pump = uniforms.sidechainPump;
float envelope = uniforms.sidechainEnvelope;
float subBass = uniforms.subBassEnergy;
// Center and aspect ratio correction
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
float2 p = uv - center;
p.x *= aspectRatio;
// Apply breathing zoom effect
float zoomAmount = 1.0 + pump * 0.3 * (0.5 + reactivity * 0.5);
p /= zoomAmount;
// Radial distortion synchronized with pump
float dist = length(p);
float angle = atan2(p.y, p.x);
// Pump-synced radial waves
float radialWave = sin(dist * 15.0 - time * 3.0 + envelope * 10.0);
radialWave *= pump * 0.3;
// Apply distortion
float2 distortedP = p;
distortedP *= 1.0 + radialWave * 0.1;
// Create concentric pulse rings
float rings = 0.0;
const int numRings = 5;
for (int i = 0; i < numRings; i++) {
float ringPhase = fract(time * 0.5 + float(i) * 0.2 - envelope * 0.5);
float ringRadius = ringPhase * 0.6;
float ringWidth = 0.02 + pump * 0.03;
float ringDist = abs(dist - ringRadius);
float ring = exp(-ringDist * ringDist / (ringWidth * ringWidth));
ring *= 1.0 - ringPhase; // Fade out as it expands
ring *= pump;
rings += ring;
}
// Breathing glow in center
float breathIntensity = 0.5 + 0.5 * sin(time * 4.0 + envelope * 6.28318);
breathIntensity *= pump;
float centerGlow = exp(-dist * dist * 8.0);
centerGlow *= breathIntensity;
// Color based on pump phase
float3 pumpColor = mix(uvViolet, neonMagenta, envelope);
float3 ringColor = mix(neonCyan, hotPink, pump);
// Background pattern - angular sectors that pulse
float sectors = 8.0;
float sectorAngle = fract(angle / (2.0 * 3.14159) * sectors);
float sectorPulse = smoothstep(0.4, 0.5, sectorAngle) - smoothstep(0.5, 0.6, sectorAngle);
sectorPulse *= pump * 0.3;
sectorPulse *= exp(-dist * 3.0);
// Spiral pattern
float spiral = fract(angle / (2.0 * 3.14159) * 3.0 + dist * 5.0 - time * 0.5);
spiral = smoothstep(0.4, 0.5, spiral) - smoothstep(0.5, 0.6, spiral);
spiral *= pump * 0.2;
spiral *= exp(-dist * 2.0);
// Compose final color
float3 finalColor = float3(0.0);
// Base gradient
float3 bgGradient = mix(deepPurple, uvViolet * 0.3, dist);
finalColor += bgGradient;
// Add rings
finalColor += ringColor * rings;
// Add center glow
finalColor += pumpColor * centerGlow;
// Add sector pulse
finalColor += neonGreen * sectorPulse;
// Add spiral
finalColor += electricBlue * spiral;
// Screen flash on strong pump
if (pump > 0.7) {
float flash = (pump - 0.7) / 0.3;
flash *= 0.2;
finalColor += neonMagenta * flash;
}
// Peak highlight
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity * 0.2;
finalColor += float3(1.0) * peakFlash * exp(-dist * 5.0);
}
// Vignette
float vignette = 1.0 - smoothstep(0.4, 0.8, dist);
finalColor *= 0.7 + vignette * 0.3;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,116 @@
//
// SubBassShader.metal
// PsytranceVisualizer
//
// Pulsating rings visualizing sub-bass energy below 100Hz
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 subBassFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
// Center coordinates
float2 center = float2(0.5, 0.5);
float aspectRatio = resolution.x / resolution.y;
// Correct for aspect ratio
float2 p = uv - center;
p.x *= aspectRatio;
float dist = length(p);
float angle = atan2(p.y, p.x);
// Main pulsating circle
float baseRadius = 0.15;
float pulseAmount = subBass * (0.5 + reactivity * 0.5);
float mainRadius = baseRadius + pulseAmount * 0.2;
// Add wobble based on angle
float wobble = sin(angle * 4.0 + time * 2.0) * 0.02 * subBass;
mainRadius += wobble;
// Core circle
float coreDist = abs(dist - mainRadius);
float coreGlow = exp(-coreDist * coreDist * 200.0);
// Inner fill with gradient
float innerFill = smoothstep(mainRadius, mainRadius * 0.3, dist);
innerFill *= 0.5 + 0.5 * subBass;
// Expanding rings
const int numRings = 6;
float ringIntensity = 0.0;
for (int i = 0; i < numRings; i++) {
// Each ring expands outward over time
float ringPhase = fract(time * 0.3 - float(i) * 0.15);
float ringRadius = mainRadius + ringPhase * 0.5;
// Get historical sub-bass value for this ring
int histIndex = clamp(int(ringPhase * 64.0), 0, 63);
float histValue = historyData[histIndex];
// Ring thickness based on historical energy
float thickness = 0.005 + histValue * 0.01;
float ringDist = abs(dist - ringRadius);
// Ring visibility
float ring = exp(-ringDist * ringDist / (thickness * thickness));
ring *= (1.0 - ringPhase); // Fade as it expands
ring *= histValue; // Intensity based on history
ringIntensity += ring;
}
// Color composition
float3 coreColor = mix(uvViolet, neonMagenta, subBass);
float3 ringColor = mix(neonMagenta, hotPink, 0.5 + 0.5 * sin(time));
float3 finalColor = float3(0.0);
// Add core
finalColor += coreColor * (innerFill + coreGlow * 2.0);
// Add rings
finalColor += ringColor * ringIntensity * 0.8;
// Add central glow
float centerGlow = exp(-dist * dist * 10.0) * subBass;
finalColor += uvViolet * centerGlow * 0.5;
// Add angular rays on peaks
if (uniforms.isPeak > 0.5) {
float rays = abs(sin(angle * 8.0 + time * 5.0));
rays = pow(rays, 4.0) * exp(-dist * 2.0);
rays *= uniforms.peakIntensity;
finalColor += neonCyan * rays * 0.5;
}
// Outer vignette
float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
finalColor *= vignette;
// Background pulse
float bgPulse = subBass * 0.1;
finalColor += deepPurple * bgPulse;
// Add noise texture for organic feel
float noiseVal = noise(p * 20.0 + time);
finalColor += uvViolet * noiseVal * 0.02 * subBass;
return float4(finalColor, 1.0);
}
@@ -0,0 +1,136 @@
//
// TunnelWarpShader.metal
// PsytranceVisualizer
//
// Infinite tunnel effect with warp distortion
//
#include <metal_stdlib>
using namespace metal;
#include "Common.metal"
fragment float4 tunnelWarpFragment(
VertexOut in [[stage_in]],
constant ShaderUniforms& uniforms [[buffer(0)]],
constant float* fftData [[buffer(1)]],
constant float* melData [[buffer(2)]],
constant float* historyData [[buffer(3)]]
) {
float2 uv = in.uv;
float2 resolution = uniforms.resolution;
float time = uniforms.time;
float reactivity = uniforms.reactivity;
float subBass = uniforms.subBassEnergy;
float pump = uniforms.sidechainPump;
float hnr = uniforms.hnrRatio;
// Center and aspect correction
float aspectRatio = resolution.x / resolution.y;
float2 p = (uv - 0.5) * 2.0;
p.x *= aspectRatio;
// Convert to polar coordinates for tunnel
float dist = length(p);
float angle = atan2(p.y, p.x);
// Avoid division by zero at center
dist = max(dist, 0.001);
// Tunnel depth (inverse of distance)
float depth = 1.0 / dist;
// Speed controlled by sub-bass
float baseSpeed = 2.0;
float audioSpeed = subBass * 3.0 * (0.5 + reactivity * 0.5);
float speed = baseSpeed + audioSpeed;
// Warp distortion from sidechain pump
float warpAmount = pump * 0.5;
depth += sin(angle * 4.0 + time * 2.0) * warpAmount * 0.5;
angle += sin(depth * 2.0 + time) * warpAmount * 0.3;
// Create tunnel coordinates
float2 tunnelUV = float2(
angle / (2.0 * 3.14159) + 0.5, // Angular coordinate [0, 1]
depth + time * speed // Depth with movement
);
// === TUNNEL WALL PATTERNS ===
// Hexagonal grid pattern
float2 hexUV = tunnelUV * float2(8.0, 2.0);
float2 hexCell = floor(hexUV);
float2 hexFrac = fract(hexUV);
// Offset every other row
if (fmod(hexCell.y, 2.0) > 0.5) {
hexFrac.x = fract(hexFrac.x + 0.5);
}
float hexDist = length(hexFrac - 0.5);
float hexPattern = smoothstep(0.4, 0.35, hexDist);
// Add concentric rings
float rings = sin(tunnelUV.y * 20.0) * 0.5 + 0.5;
rings = smoothstep(0.3, 0.7, rings);
// Angular segments
float segments = 8.0;
float angularLines = abs(sin(angle * segments));
angularLines = smoothstep(0.95, 1.0, angularLines);
// Combine patterns
float pattern = hexPattern * 0.5 + rings * 0.3 + angularLines * 0.2;
// === COLORING ===
// Base color cycles with depth and time
float colorPhase = tunnelUV.y * 0.1 + time * 0.2;
float3 tunnelColor = psytrancePalette(colorPhase, time);
// Depth fog (darker towards center/infinity)
float fog = exp(-dist * 2.0);
tunnelColor *= fog;
// Pattern overlay
float3 patternColor = mix(uvViolet, neonCyan, rings);
tunnelColor = mix(tunnelColor, patternColor, pattern * 0.5);
// Edge glow (bright at tunnel edges)
float edgeGlow = exp(-dist * 5.0);
tunnelColor = addGlow(tunnelColor, (1.0 - edgeGlow) * 0.3, neonMagenta);
// Center light (looking into the tunnel)
float centerLight = exp(-dist * dist * 50.0);
tunnelColor += float3(1.0) * centerLight * 0.5;
// HNR affects pattern complexity
float patternIntensity = hnr;
tunnelColor *= 0.7 + patternIntensity * 0.3;
// Add noise for texture
float noiseVal = noise(tunnelUV * 10.0 + time);
tunnelColor += uvViolet * noiseVal * 0.1;
// Pump flash
if (pump > 0.5) {
float pumpFlash = (pump - 0.5) * 2.0;
tunnelColor += neonMagenta * pumpFlash * 0.2;
}
// Peak flash
if (uniforms.isPeak > 0.5) {
float peakFlash = uniforms.peakIntensity;
tunnelColor += float3(1.0) * peakFlash * 0.15 * (1.0 - edgeGlow);
}
// Speed lines effect
float speedLines = fract(tunnelUV.y * 50.0 - time * speed * 2.0);
speedLines = smoothstep(0.95, 1.0, speedLines);
speedLines *= subBass * 0.5;
tunnelColor += neonCyan * speedLines;
return float4(tunnelColor, 1.0);
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+38
View File
@@ -0,0 +1,38 @@
<?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>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></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>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Psytrance Visualizer needs access to your audio input to visualize music in real-time. You can use a virtual audio device like BlackHole to route system audio.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,12 @@
<?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.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
+315
View File
@@ -0,0 +1,315 @@
//
// ControlPanel.swift
// PsytranceVisualizer
//
// Auto-hiding control panel with audio and visualization settings
//
import AppKit
import Combine
/// Delegate protocol for control panel actions
protocol ControlPanelDelegate: AnyObject {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String)
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int)
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode)
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float)
func controlPanelDidRequestFullscreen(_ panel: ControlPanel)
}
/// Auto-hiding control panel overlay
final class ControlPanel: NSView {
// MARK: - Properties
weak var delegate: ControlPanelDelegate?
private var isVisible = true
private var hideTimer: Timer?
private let hideDelay: TimeInterval = 3.0
private var audioDevices: [AudioDevice] = []
private var selectedMode: VisualizationMode = .fftClassic
// MARK: - UI Elements
private let containerView = NSVisualEffectView()
private let devicePopup = NSPopUpButton()
private let bufferSizePopup = NSPopUpButton()
private let modeSegment = NSSegmentedControl()
private let reactivitySlider = NSSlider()
private let reactivityLabel = NSTextField(labelWithString: "Reactivity")
private let fullscreenButton = NSButton()
// MARK: - Layout Constants
private let panelHeight: CGFloat = 60
private let padding: CGFloat = 12
private let elementHeight: CGFloat = 24
// MARK: - Initialization
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupUI()
setupConstraints()
startHideTimer()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
// Container with vibrancy effect
containerView.material = .hudWindow
containerView.blendingMode = .behindWindow
containerView.state = .active
containerView.wantsLayer = true
containerView.layer?.cornerRadius = 12
containerView.layer?.masksToBounds = true
addSubview(containerView)
// Device popup
devicePopup.target = self
devicePopup.action = #selector(deviceChanged)
devicePopup.controlSize = .small
devicePopup.font = .systemFont(ofSize: 11)
containerView.addSubview(devicePopup)
// Buffer size popup
bufferSizePopup.target = self
bufferSizePopup.action = #selector(bufferSizeChanged)
bufferSizePopup.controlSize = .small
bufferSizePopup.font = .systemFont(ofSize: 11)
bufferSizePopup.addItems(withTitles: ["512", "1024"])
bufferSizePopup.selectItem(withTitle: "1024")
containerView.addSubview(bufferSizePopup)
// Mode segment control
modeSegment.segmentCount = 8
for mode in VisualizationMode.allCases {
modeSegment.setLabel(mode.shortcut, forSegment: mode.rawValue - 1)
modeSegment.setToolTip(mode.displayName, forSegment: mode.rawValue - 1)
}
modeSegment.selectedSegment = 0
modeSegment.target = self
modeSegment.action = #selector(modeChanged)
modeSegment.controlSize = .small
modeSegment.segmentStyle = .capsule
containerView.addSubview(modeSegment)
// Reactivity label
reactivityLabel.font = .systemFont(ofSize: 10)
reactivityLabel.textColor = .secondaryLabelColor
containerView.addSubview(reactivityLabel)
// Reactivity slider
reactivitySlider.minValue = 0.0
reactivitySlider.maxValue = 1.0
reactivitySlider.doubleValue = 0.5
reactivitySlider.target = self
reactivitySlider.action = #selector(reactivityChanged)
reactivitySlider.controlSize = .small
containerView.addSubview(reactivitySlider)
// Fullscreen button
fullscreenButton.title = ""
fullscreenButton.bezelStyle = .accessoryBarAction
fullscreenButton.target = self
fullscreenButton.action = #selector(fullscreenClicked)
fullscreenButton.toolTip = "Toggle Fullscreen (F)"
containerView.addSubview(fullscreenButton)
// Set colors
applyPsytranceTheme()
}
private func applyPsytranceTheme() {
// Custom appearance for psytrance aesthetic
containerView.appearance = NSAppearance(named: .darkAqua)
}
private func setupConstraints() {
containerView.translatesAutoresizingMaskIntoConstraints = false
devicePopup.translatesAutoresizingMaskIntoConstraints = false
bufferSizePopup.translatesAutoresizingMaskIntoConstraints = false
modeSegment.translatesAutoresizingMaskIntoConstraints = false
reactivityLabel.translatesAutoresizingMaskIntoConstraints = false
reactivitySlider.translatesAutoresizingMaskIntoConstraints = false
fullscreenButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Container
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding),
containerView.heightAnchor.constraint(equalToConstant: panelHeight),
// Device popup
devicePopup.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding),
devicePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
devicePopup.widthAnchor.constraint(equalToConstant: 150),
// Buffer size popup
bufferSizePopup.leadingAnchor.constraint(equalTo: devicePopup.trailingAnchor, constant: 8),
bufferSizePopup.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
bufferSizePopup.widthAnchor.constraint(equalToConstant: 60),
// Mode segment
modeSegment.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
modeSegment.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity label
reactivityLabel.trailingAnchor.constraint(equalTo: reactivitySlider.leadingAnchor, constant: -4),
reactivityLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
// Reactivity slider
reactivitySlider.trailingAnchor.constraint(equalTo: fullscreenButton.leadingAnchor, constant: -padding),
reactivitySlider.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
reactivitySlider.widthAnchor.constraint(equalToConstant: 80),
// Fullscreen button
fullscreenButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding),
fullscreenButton.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
])
}
// MARK: - Public Methods
/// Updates the list of available audio devices
func updateDevices(_ devices: [AudioDevice], selectedUID: String?) {
audioDevices = devices
devicePopup.removeAllItems()
for device in devices {
devicePopup.addItem(withTitle: device.name)
devicePopup.lastItem?.representedObject = device.uid
}
if let uid = selectedUID,
let index = devices.firstIndex(where: { $0.uid == uid }) {
devicePopup.selectItem(at: index)
}
}
/// Updates the selected buffer size
func updateBufferSize(_ size: Int) {
bufferSizePopup.selectItem(withTitle: "\(size)")
}
/// Updates the selected visualization mode
func updateMode(_ mode: VisualizationMode) {
selectedMode = mode
modeSegment.selectedSegment = mode.rawValue - 1
}
/// Updates the reactivity slider
func updateReactivity(_ value: Float) {
reactivitySlider.doubleValue = Double(value)
}
/// Shows the control panel
func show(animated: Bool = true) {
guard !isVisible else { return }
isVisible = true
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 1.0
}
} else {
alphaValue = 1.0
}
startHideTimer()
}
/// Hides the control panel
func hide(animated: Bool = true) {
guard isVisible else { return }
isVisible = false
hideTimer?.invalidate()
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
self.animator().alphaValue = 0.0
}
} else {
alphaValue = 0.0
}
}
/// Resets the hide timer (call on mouse movement)
func resetHideTimer() {
show()
startHideTimer()
}
// MARK: - Private Methods
private func startHideTimer() {
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: hideDelay, repeats: false) { [weak self] _ in
self?.hide()
}
}
// MARK: - Actions
@objc private func deviceChanged() {
guard let uid = devicePopup.selectedItem?.representedObject as? String else { return }
delegate?.controlPanel(self, didSelectDevice: uid)
}
@objc private func bufferSizeChanged() {
guard let title = bufferSizePopup.selectedItem?.title,
let size = Int(title) else { return }
delegate?.controlPanel(self, didSelectBufferSize: size)
}
@objc private func modeChanged() {
let modeIndex = modeSegment.selectedSegment + 1
guard let mode = VisualizationMode(rawValue: modeIndex) else { return }
selectedMode = mode
delegate?.controlPanel(self, didSelectMode: mode)
}
@objc private func reactivityChanged() {
let value = Float(reactivitySlider.doubleValue)
delegate?.controlPanel(self, didChangeReactivity: value)
}
@objc private func fullscreenClicked() {
delegate?.controlPanelDidRequestFullscreen(self)
}
// MARK: - Mouse Tracking
override func updateTrackingAreas() {
super.updateTrackingAreas()
// Remove existing tracking areas
for area in trackingAreas {
removeTrackingArea(area)
}
// Add new tracking area
let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeAlways]
let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
resetHideTimer()
}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
show()
}
}
+323
View File
@@ -0,0 +1,323 @@
//
// MainWindow.swift
// PsytranceVisualizer
//
// Main application window with keyboard handling
//
import AppKit
import Combine
/// Main window controller for the visualizer
final class MainWindowController: NSWindowController {
// MARK: - Properties
private var visualizerView: VisualizerView!
private var controlPanel: ControlPanel!
private var audioManager: AudioInputManager!
private var dspEngine: DSPEngine!
private var settingsManager: SettingsManager { .shared }
private var cancellables = Set<AnyCancellable>()
private var displayLink: CVDisplayLink?
// MARK: - Initialization
convenience init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 720),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Psytrance Visualizer"
window.minSize = NSSize(width: 800, height: 600)
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.backgroundColor = .black
window.collectionBehavior = [.fullScreenPrimary]
// Restore window frame if saved
if let savedFrame = SettingsManager.shared.settings.windowFrame?.cgRect {
window.setFrame(savedFrame, display: false)
} else {
window.center()
}
self.init(window: window)
setupContent()
setupAudio()
setupKeyboardHandling()
restoreSettings()
}
// MARK: - Setup
private func setupContent() {
guard let contentView = window?.contentView else { return }
// Visualizer view (fills entire window)
visualizerView = VisualizerView()
visualizerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(visualizerView)
// Control panel (overlay at bottom)
controlPanel = ControlPanel()
controlPanel.translatesAutoresizingMaskIntoConstraints = false
controlPanel.delegate = self
contentView.addSubview(controlPanel)
NSLayoutConstraint.activate([
// Visualizer fills entire window
visualizerView.topAnchor.constraint(equalTo: contentView.topAnchor),
visualizerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
visualizerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
visualizerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Control panel at bottom
controlPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
controlPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
controlPanel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
controlPanel.heightAnchor.constraint(equalToConstant: 90),
])
// Mouse tracking for control panel
setupMouseTracking()
}
private func setupAudio() {
audioManager = AudioInputManager()
dspEngine = DSPEngine(bufferSize: settingsManager.settings.bufferSize)
// Audio buffer callback
audioManager.onAudioBuffer = { [weak self] buffer in
guard let self = self else { return }
let analysisData = self.dspEngine.process(buffer: buffer)
DispatchQueue.main.async {
self.visualizerView.updateAudioData(analysisData)
}
}
// Update control panel when devices change
audioManager.$availableDevices
.receive(on: DispatchQueue.main)
.sink { [weak self] devices in
self?.controlPanel.updateDevices(
devices,
selectedUID: self?.settingsManager.settings.selectedAudioDeviceUID
)
}
.store(in: &cancellables)
// Start audio
audioManager.start()
}
private func setupKeyboardHandling() {
// Monitor for key events
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if self?.handleKeyDown(event) == true {
return nil // Event handled
}
return event
}
}
private func setupMouseTracking() {
guard let contentView = window?.contentView else { return }
let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect]
let trackingArea = NSTrackingArea(
rect: contentView.bounds,
options: options,
owner: self,
userInfo: nil
)
contentView.addTrackingArea(trackingArea)
}
private func restoreSettings() {
let settings = settingsManager.settings
// Restore visualization mode
if let mode = VisualizationMode(rawValue: settings.lastVisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
}
// Restore reactivity
visualizerView.setReactivity(settings.reactivity)
dspEngine.setReactivity(settings.reactivity)
controlPanel.updateReactivity(settings.reactivity)
// Restore buffer size
dspEngine.setBufferSize(settings.bufferSize)
audioManager.setBufferSize(settings.bufferSize)
controlPanel.updateBufferSize(settings.bufferSize)
// Restore audio device
if let deviceUID = settings.selectedAudioDeviceUID {
audioManager.selectDevice(uid: deviceUID)
}
// Restore fullscreen state
if settings.isFullscreen {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.window?.toggleFullScreen(nil)
}
}
}
// MARK: - Keyboard Handling
private func handleKeyDown(_ event: NSEvent) -> Bool {
// Check for visualization mode shortcuts (1-8)
if let mode = VisualizationMode.fromKeyCode(event.keyCode) {
setVisualizationMode(mode)
return true
}
// Other keyboard shortcuts
switch event.keyCode {
case 3: // F key
toggleFullscreen()
return true
case 53: // Escape
if window?.styleMask.contains(.fullScreen) == true {
window?.toggleFullScreen(nil)
}
return true
case 49: // Space
// Toggle pause (could be implemented)
return true
default:
break
}
// Cmd+F for fullscreen
if event.modifierFlags.contains(.command) && event.keyCode == 3 {
toggleFullscreen()
return true
}
return false
}
// MARK: - Mode Switching
private func setVisualizationMode(_ mode: VisualizationMode) {
visualizerView.setVisualizationMode(mode)
controlPanel.updateMode(mode)
settingsManager.setVisualizationMode(mode)
}
// MARK: - Fullscreen
private func toggleFullscreen() {
window?.toggleFullScreen(nil)
}
// MARK: - Mouse Events
override func mouseMoved(with event: NSEvent) {
controlPanel.resetHideTimer()
}
// MARK: - Window Events
override func windowDidLoad() {
super.windowDidLoad()
// Save window frame on move/resize
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidResize),
name: NSWindow.didResizeNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidMove),
name: NSWindow.didMoveNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidEnterFullScreen),
name: NSWindow.didEnterFullScreenNotification,
object: window
)
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidExitFullScreen),
name: NSWindow.didExitFullScreenNotification,
object: window
)
}
@objc private func windowDidResize(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidMove(_ notification: Notification) {
if let frame = window?.frame {
settingsManager.setWindowFrame(frame)
}
}
@objc private func windowDidEnterFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(true)
controlPanel.hide()
}
@objc private func windowDidExitFullScreen(_ notification: Notification) {
settingsManager.setFullscreen(false)
controlPanel.show()
}
// MARK: - Cleanup
deinit {
audioManager.stop()
settingsManager.saveNow()
}
}
// MARK: - ControlPanelDelegate
extension MainWindowController: ControlPanelDelegate {
func controlPanel(_ panel: ControlPanel, didSelectDevice uid: String) {
audioManager.selectDevice(uid: uid)
settingsManager.setAudioDevice(uid: uid)
}
func controlPanel(_ panel: ControlPanel, didSelectBufferSize size: Int) {
audioManager.setBufferSize(size)
dspEngine.setBufferSize(size)
settingsManager.setBufferSize(size)
}
func controlPanel(_ panel: ControlPanel, didSelectMode mode: VisualizationMode) {
setVisualizationMode(mode)
}
func controlPanel(_ panel: ControlPanel, didChangeReactivity value: Float) {
visualizerView.setReactivity(value)
dspEngine.setReactivity(value)
settingsManager.setReactivity(value)
}
func controlPanelDidRequestFullscreen(_ panel: ControlPanel) {
toggleFullscreen()
}
}
+122
View File
@@ -0,0 +1,122 @@
//
// VisualizerView.swift
// PsytranceVisualizer
//
// MTKView subclass for rendering visualizations
//
import MetalKit
import Combine
/// MTKView subclass that displays audio-reactive visualizations
final class VisualizerView: MTKView {
// MARK: - Properties
private var renderer: MetalRenderer?
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init() {
// Get default Metal device
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("Metal is not supported on this device")
}
super.init(frame: .zero, device: device)
configure()
setupRenderer()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Configuration
private func configure() {
// Background color
clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
// Color format
colorPixelFormat = .bgra8Unorm
// Enable display link for smooth rendering
isPaused = false
enableSetNeedsDisplay = false
// Use display refresh rate
preferredFramesPerSecond = 120 // Will cap to display refresh
// Layer configuration
layer?.isOpaque = true
// Allow high DPI
layer?.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
}
private func setupRenderer() {
guard let device = device else { return }
renderer = MetalRenderer(device: device)
delegate = renderer
// Initial size update
if let renderer = renderer {
let size = drawableSize
renderer.mtkView(self, drawableSizeWillChange: size)
}
}
// MARK: - Public Methods
/// Returns the Metal renderer
func getRenderer() -> MetalRenderer? {
return renderer
}
/// Updates audio data for visualization
func updateAudioData(_ data: AudioAnalysisData) {
renderer?.updateAudioData(data)
}
/// Sets the visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
renderer?.setVisualizationMode(mode)
}
/// Sets reactivity value
func setReactivity(_ value: Float) {
renderer?.setReactivity(value)
}
/// Gets current visualization mode
var currentMode: VisualizationMode {
renderer?.currentMode ?? .fftClassic
}
}
// MARK: - SwiftUI Bridge
#if canImport(SwiftUI)
import SwiftUI
/// SwiftUI wrapper for VisualizerView
struct VisualizerViewRepresentable: NSViewRepresentable {
@Binding var audioData: AudioAnalysisData
@Binding var mode: VisualizationMode
@Binding var reactivity: Float
func makeNSView(context: Context) -> VisualizerView {
let view = VisualizerView()
return view
}
func updateNSView(_ nsView: VisualizerView, context: Context) {
nsView.updateAudioData(audioData)
nsView.setVisualizationMode(mode)
nsView.setReactivity(reactivity)
}
}
#endif
@@ -0,0 +1,140 @@
//
// ColorPalette.swift
// PsytranceVisualizer
//
// Psytrance color palette for UI and shaders
//
import AppKit
import simd
/// Psytrance-inspired neon/UV color palette
struct PsytranceColors {
// MARK: - Primary Colors (NSColor for UI)
/// Neon Magenta - Primary accent color
static let neonMagenta = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 1.0)
/// Neon Cyan - Secondary accent color
static let neonCyan = NSColor(red: 0.0, green: 1.0, blue: 1.0, alpha: 1.0)
/// Neon Green - High energy accents
static let neonGreen = NSColor(red: 0.224, green: 1.0, blue: 0.078, alpha: 1.0)
/// UV Violet - Deep purple for backgrounds
static let uvViolet = NSColor(red: 0.482, green: 0.0, blue: 1.0, alpha: 1.0)
/// Deep Black - Background color
static let background = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
/// Dark Purple - Alternative background
static let darkPurple = NSColor(red: 0.1, green: 0.0, blue: 0.15, alpha: 1.0)
/// Hot Pink - Peak indicators
static let hotPink = NSColor(red: 1.0, green: 0.2, blue: 0.6, alpha: 1.0)
/// Electric Blue - UI elements
static let electricBlue = NSColor(red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0)
// MARK: - SIMD3<Float> Colors (for Metal shaders)
struct Metal {
static let neonMagenta = SIMD3<Float>(1.0, 0.0, 1.0)
static let neonCyan = SIMD3<Float>(0.0, 1.0, 1.0)
static let neonGreen = SIMD3<Float>(0.224, 1.0, 0.078)
static let uvViolet = SIMD3<Float>(0.482, 0.0, 1.0)
static let background = SIMD3<Float>(0.0, 0.0, 0.0)
static let darkPurple = SIMD3<Float>(0.1, 0.0, 0.15)
static let hotPink = SIMD3<Float>(1.0, 0.2, 0.6)
static let electricBlue = SIMD3<Float>(0.0, 0.5, 1.0)
/// Array of all palette colors for cycling
static let palette: [SIMD3<Float>] = [
neonMagenta,
neonCyan,
neonGreen,
uvViolet,
hotPink,
electricBlue
]
/// Get color from palette by index (wraps around)
static func color(at index: Int) -> SIMD3<Float> {
palette[index % palette.count]
}
/// Interpolate between two colors
static func lerp(_ a: SIMD3<Float>, _ b: SIMD3<Float>, t: Float) -> SIMD3<Float> {
a + (b - a) * t
}
/// Get rainbow color from normalized value (0-1)
static func rainbow(_ t: Float) -> SIMD3<Float> {
let index = Int(t * Float(palette.count))
let nextIndex = (index + 1) % palette.count
let localT = (t * Float(palette.count)) - Float(index)
return lerp(palette[index % palette.count], palette[nextIndex], t: localT)
}
}
// MARK: - Gradient Helpers
/// Creates a gradient from UV Violet through Magenta to Cyan
static var spectrumGradient: NSGradient? {
NSGradient(colors: [uvViolet, neonMagenta, hotPink, neonCyan, neonGreen])
}
/// Creates a gradient for heat maps (low to high energy)
static var heatmapGradient: NSGradient? {
NSGradient(colors: [
NSColor(red: 0.1, green: 0.0, blue: 0.2, alpha: 1.0), // Dark purple (low)
uvViolet,
neonMagenta,
hotPink,
neonCyan,
neonGreen,
NSColor.white // White (peak)
])
}
// MARK: - UI Theme Colors
struct UI {
static let panelBackground = NSColor(red: 0.05, green: 0.02, blue: 0.08, alpha: 0.9)
static let buttonBackground = NSColor(red: 0.15, green: 0.05, blue: 0.2, alpha: 1.0)
static let buttonHighlight = neonMagenta.withAlphaComponent(0.8)
static let sliderTint = neonCyan
static let labelText = NSColor.white
static let secondaryText = NSColor(white: 0.7, alpha: 1.0)
static let border = uvViolet.withAlphaComponent(0.5)
}
}
// MARK: - NSColor Extension
extension NSColor {
/// Converts NSColor to SIMD3<Float> for Metal
var simd3: SIMD3<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD3<Float>(0, 0, 0)
}
return SIMD3<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent)
)
}
/// Converts NSColor to SIMD4<Float> for Metal (with alpha)
var simd4: SIMD4<Float> {
guard let rgb = usingColorSpace(.deviceRGB) else {
return SIMD4<Float>(0, 0, 0, 1)
}
return SIMD4<Float>(
Float(rgb.redComponent),
Float(rgb.greenComponent),
Float(rgb.blueComponent),
Float(rgb.alphaComponent)
)
}
}
@@ -0,0 +1,185 @@
//
// SettingsManager.swift
// PsytranceVisualizer
//
// Handles loading and saving of application settings
//
import Foundation
import Combine
/// Manages persistent storage and retrieval of application settings
final class SettingsManager: ObservableObject {
// MARK: - Singleton
static let shared = SettingsManager()
// MARK: - Published Properties
@Published private(set) var settings: AppSettings
// MARK: - Private Properties
private let settingsKey = "PsytranceVisualizerSettings"
private let fileManager = FileManager.default
private var saveWorkItem: DispatchWorkItem?
// MARK: - Initialization
private init() {
self.settings = SettingsManager.loadSettings()
}
// MARK: - Public Methods
/// Updates settings and triggers auto-save
func updateSettings(_ update: (inout AppSettings) -> Void) {
update(&settings)
settings.validate()
scheduleSave()
}
/// Updates selected audio device
func setAudioDevice(uid: String?) {
updateSettings { $0.selectedAudioDeviceUID = uid }
}
/// Updates buffer size
func setBufferSize(_ size: Int) {
guard AppSettings.availableBufferSizes.contains(size) else { return }
updateSettings { $0.bufferSize = size }
}
/// Updates visualization mode
func setVisualizationMode(_ mode: VisualizationMode) {
updateSettings { $0.lastVisualizationMode = mode.rawValue }
}
/// Updates reactivity
func setReactivity(_ value: Float) {
updateSettings { $0.reactivity = max(0.0, min(1.0, value)) }
}
/// Updates fullscreen state
func setFullscreen(_ isFullscreen: Bool) {
updateSettings { $0.isFullscreen = isFullscreen }
}
/// Updates window frame
func setWindowFrame(_ frame: CGRect) {
updateSettings { $0.windowFrame = CodableRect(from: frame) }
}
/// Updates input gain
func setInputGain(_ gain: Float) {
updateSettings { $0.inputGain = max(0.0, min(2.0, gain)) }
}
/// Updates FPS display setting
func setShowFPS(_ show: Bool) {
updateSettings { $0.showFPS = show }
}
/// Forces immediate save
func saveNow() {
saveWorkItem?.cancel()
performSave()
}
/// Resets to default settings
func resetToDefaults() {
settings = .default
saveNow()
}
// MARK: - Private Methods
/// Schedules a debounced save operation
private func scheduleSave() {
saveWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.performSave()
}
saveWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
}
/// Performs the actual save operation
private func performSave() {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(settings)
// Save to UserDefaults
UserDefaults.standard.set(data, forKey: settingsKey)
// Also save to file for backup
if let url = settingsFileURL {
try data.write(to: url)
}
print("[SettingsManager] Settings saved successfully")
} catch {
print("[SettingsManager] Failed to save settings: \(error)")
}
}
/// Loads settings from storage
private static func loadSettings() -> AppSettings {
// Try UserDefaults first
if let data = UserDefaults.standard.data(forKey: "PsytranceVisualizerSettings") {
do {
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
settings.validate()
print("[SettingsManager] Settings loaded from UserDefaults")
return settings
} catch {
print("[SettingsManager] Failed to decode settings from UserDefaults: \(error)")
}
}
// Try file backup
if let url = settingsFileURL,
let data = try? Data(contentsOf: url) {
do {
var settings = try JSONDecoder().decode(AppSettings.self, from: data)
settings.validate()
print("[SettingsManager] Settings loaded from file")
return settings
} catch {
print("[SettingsManager] Failed to decode settings from file: \(error)")
}
}
print("[SettingsManager] Using default settings")
return .default
}
/// URL for settings file backup
private static var settingsFileURL: URL? {
guard let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return nil
}
let appDirectory = appSupport.appendingPathComponent("PsytranceVisualizer")
// Create directory if needed
try? FileManager.default.createDirectory(
at: appDirectory,
withIntermediateDirectories: true
)
return appDirectory.appendingPathComponent("settings.json")
}
/// Current visualization mode
var currentVisualizationMode: VisualizationMode {
VisualizationMode(rawValue: settings.lastVisualizationMode) ?? .fftClassic
}
}
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
.dctp_backups/
.dctp_settings.json
+102
View File
@@ -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
View File
@@ -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
+305
View File
@@ -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()
+385
View File
@@ -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()
+584
View File
@@ -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()
+666
View File
@@ -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()
+307
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
customtkinter>=5.2.0
+48
View File
@@ -0,0 +1,48 @@
# FamilyAlbums - Apache Configuration
# Security Headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Deny access to config file
<Files "config.php">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</Files>
# Deny access to hidden files
<FilesMatch "^\.">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript
</IfModule>
# Cache static assets
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
</IfModule>
# Default charset
AddDefaultCharset UTF-8
+131
View File
@@ -0,0 +1,131 @@
# FamilyAlbums - Familien-Fotoalbum-Portal
Ein einfaches, PHP-basiertes Portal zur Verwaltung und Anzeige von Familien-Fotoalben mit Links zu Nextcloud.
## Features
- Öffentliche Galerie-Ansicht mit Jahr/Monat-Filter
- Stichwortsuche über Titel, Tags und Beschreibung
- Kommentarfunktion für Familienmitglieder
- Admin-Interface zur Albumverwaltung
- Responsive Design (Tailwind CSS)
- Flat-File Datenbank (JSON) - kein MySQL erforderlich
- Spam-Schutz (Honeypot + Rate-Limiting)
- CSRF-Schutz für Admin-Aktionen
## Installation
### 1. Dateien kopieren
```bash
# Auf den Webserver kopieren
sudo cp -r familyalbums /var/www/
# Berechtigungen setzen
sudo chown -R www-data:www-data /var/www/familyalbums
sudo chmod -R 755 /var/www/familyalbums
sudo chmod 770 /var/www/familyalbums/data
sudo chmod 770 /var/www/familyalbums/thumbnails
```
### 2. Admin-Passwort ändern
**WICHTIG:** Das Standard-Passwort muss vor dem produktiven Einsatz geändert werden!
```bash
# Neuen Passwort-Hash generieren
php -r "echo password_hash('DeinSicheresPasswort', PASSWORD_DEFAULT);"
```
Den generierten Hash in `config.php` eintragen:
```php
define('ADMIN_PASSWORD_HASH', '$2y$10$DEIN_GENERIERTER_HASH_HIER');
```
### 3. Apache Virtual Host (optional)
```apache
<VirtualHost *:80>
ServerName familyalbums.example.com
DocumentRoot /var/www/familyalbums
<Directory /var/www/familyalbums>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
## Verwendung
### Öffentliche Galerie
- URL: `https://deine-domain.ch/`
- Filter nach Jahr und Monat
- Stichwortsuche
- Kommentare zu Alben hinterlassen
### Admin-Bereich
- URL: `https://deine-domain.ch/admin.php`
- Login mit dem konfigurierten Passwort
- Alben hinzufügen, bearbeiten, löschen
- Optional: Vorschaubilder hochladen
- Kommentare moderieren
## Datenstruktur
### albums.json
```json
{
"albums": [
{
"id": "uuid",
"title": "Albumtitel",
"url": "https://nextcloud.../apps/photos/public/...",
"date": "2024-12-25",
"tags": ["tag1", "tag2"],
"description": "Beschreibung",
"thumbnail": "thumbnails/bild.jpg",
"created_at": "2024-12-26T10:00:00+01:00"
}
]
}
```
### comments.json
```json
{
"comments": [
{
"id": "uuid",
"album_id": "album-uuid",
"author": "Name",
"text": "Kommentar",
"created_at": "2024-12-27T14:30:00+01:00"
}
]
}
```
## Sicherheit
- Admin-Passwort mit bcrypt gehasht
- CSRF-Token für alle Admin-Aktionen
- XSS-Schutz durch `htmlspecialchars()`
- Rate-Limiting für Kommentare (5/Minute pro IP)
- Honeypot-Feld gegen Spam-Bots
- `.htaccess` schützt config.php und data/
## Anforderungen
- PHP 8.0+
- Apache mit mod_rewrite (optional)
- Schreibrechte für data/ und thumbnails/
## Lizenz
Privates Projekt für Familien-Nutzung.
+658
View File
@@ -0,0 +1,658 @@
<?php
/**
* FamilyAlbums - Admin Interface
*/
require_once __DIR__ . '/config.php';
session_start();
$pageTitle = SITE_TITLE . ' - Administration';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; }
.tag-item { background: #dbeafe; color: #1d4ed8; padding: 0.25rem 0.5rem; border-radius: 9999px; display: flex; align-items: center; gap: 0.25rem; }
.tag-item button { color: #1d4ed8; cursor: pointer; }
.tag-input input { flex: 1; min-width: 100px; border: none; outline: none; }
.suggestions { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #d1d5db; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 10; }
.suggestions div { padding: 0.5rem 1rem; cursor: pointer; }
.suggestions div:hover { background: #f3f4f6; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- Login-Bereich (wird per JS gesteuert) -->
<div id="login-section" class="hidden min-h-screen flex items-center justify-center">
<div class="bg-white p-8 rounded-xl shadow-lg w-full max-w-md">
<h1 class="text-2xl font-bold text-center mb-6">
<i class="fas fa-lock mr-2 text-blue-600"></i>Admin Login
</h1>
<form id="login-form">
<div class="mb-4">
<label class="block text-gray-700 mb-2">Passwort</label>
<input type="password" id="login-password" required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Admin-Passwort eingeben">
</div>
<div id="login-error" class="hidden text-red-500 text-sm mb-4"></div>
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition">
<i class="fas fa-sign-in-alt mr-2"></i>Anmelden
</button>
</form>
<p class="mt-4 text-center">
<a href="index.php" class="text-blue-600 hover:underline">
<i class="fas fa-arrow-left mr-1"></i>Zurück zur Galerie
</a>
</p>
</div>
</div>
<!-- Admin-Bereich -->
<div id="admin-section" class="hidden">
<!-- Header -->
<header class="bg-gradient-to-r from-gray-800 to-gray-900 text-white shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold">
<i class="fas fa-cog mr-2"></i><?= e($pageTitle) ?>
</h1>
<div class="flex items-center gap-4">
<a href="index.php" class="text-white/80 hover:text-white">
<i class="fas fa-eye mr-1"></i>Galerie
</a>
<button onclick="logout()" class="text-white/80 hover:text-white">
<i class="fas fa-sign-out-alt mr-1"></i>Logout
</button>
</div>
</div>
</div>
</header>
<!-- Tabs -->
<div class="bg-white shadow">
<div class="container mx-auto px-4">
<nav class="flex gap-4">
<button onclick="showTab('albums')" id="tab-albums"
class="tab-btn py-4 px-2 border-b-2 border-blue-600 text-blue-600 font-medium">
<i class="fas fa-images mr-1"></i>Alben
</button>
<button onclick="showTab('comments')" id="tab-comments"
class="tab-btn py-4 px-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700">
<i class="fas fa-comments mr-1"></i>Kommentare
</button>
</nav>
</div>
</div>
<!-- Content -->
<main class="container mx-auto px-4 py-8">
<!-- Alben-Tab -->
<div id="content-albums">
<!-- Album hinzufügen -->
<div class="bg-white rounded-xl shadow-md p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-plus-circle mr-2 text-green-600"></i>
<span id="form-title">Neues Album hinzufügen</span>
</h2>
<form id="album-form" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" id="album-id">
<div>
<label class="block text-gray-700 mb-1">Titel *</label>
<input type="text" id="album-title" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="z.B. Weihnachten bei Oma">
</div>
<div>
<label class="block text-gray-700 mb-1">Datum *</label>
<input type="date" id="album-date" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Nextcloud-Link *</label>
<input type="url" id="album-url" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="https://nextcloud.example.com/apps/photos/public/...">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Beschreibung</label>
<textarea id="album-description" rows="2"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Kurze Beschreibung des Albums"></textarea>
</div>
<div class="md:col-span-2 relative">
<label class="block text-gray-700 mb-1">Tags</label>
<div class="tag-input" id="tags-container">
<input type="text" id="tag-input" placeholder="Tag eingeben und Enter drücken">
</div>
<div id="tag-suggestions" class="suggestions hidden"></div>
<input type="hidden" id="album-tags">
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1">Vorschaubild (optional)</label>
<div class="flex gap-2">
<input type="file" id="thumbnail-file" accept="image/*"
class="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<button type="button" onclick="uploadThumbnail()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
<i class="fas fa-upload"></i>
</button>
</div>
<input type="hidden" id="album-thumbnail">
<div id="thumbnail-preview" class="mt-2"></div>
</div>
<div class="md:col-span-2 flex gap-2">
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded-lg hover:bg-green-700 transition">
<i class="fas fa-save mr-2"></i><span id="submit-text">Speichern</span>
</button>
<button type="button" onclick="resetForm()" class="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300 transition">
<i class="fas fa-times mr-2"></i>Abbrechen
</button>
</div>
</form>
</div>
<!-- Album-Liste -->
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-list mr-2 text-blue-600"></i>Alle Alben
</h2>
<div id="albums-list" class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-gray-600">Titel</th>
<th class="px-4 py-3 text-left text-gray-600">Datum</th>
<th class="px-4 py-3 text-left text-gray-600">Tags</th>
<th class="px-4 py-3 text-right text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody id="albums-table-body">
<!-- Wird per JS befüllt -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Kommentare-Tab -->
<div id="content-comments" class="hidden">
<div class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-xl font-semibold mb-4">
<i class="fas fa-comments mr-2 text-blue-600"></i>Alle Kommentare
</h2>
<div id="comments-list" class="space-y-4">
<!-- Wird per JS befüllt -->
</div>
</div>
</div>
</main>
</div>
<!-- Bestätigungs-Modal -->
<div id="confirm-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-6">
<h3 class="text-lg font-semibold mb-4" id="confirm-title">Bestätigung</h3>
<p id="confirm-message" class="text-gray-600 mb-6"></p>
<div class="flex justify-end gap-2">
<button onclick="closeConfirm()" class="px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300">
Abbrechen
</button>
<button id="confirm-btn" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Löschen
</button>
</div>
</div>
</div>
<script>
// === State ===
let csrfToken = '';
let allTags = [];
let currentTags = [];
let editingAlbumId = null;
let confirmCallback = null;
// === Auth ===
async function checkAuth() {
const response = await fetch('api.php?action=check_auth');
const data = await response.json();
if (data.authenticated) {
csrfToken = data.csrf;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('admin-section').classList.remove('hidden');
loadAlbums();
loadAllTags();
} else {
document.getElementById('login-section').classList.remove('hidden');
document.getElementById('admin-section').classList.add('hidden');
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('login-password').value;
const errorDiv = document.getElementById('login-error');
try {
const response = await fetch('api.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.success) {
csrfToken = data.csrf;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('admin-section').classList.remove('hidden');
loadAlbums();
loadAllTags();
} else {
errorDiv.textContent = data.error || 'Login fehlgeschlagen';
errorDiv.classList.remove('hidden');
}
} catch (err) {
errorDiv.textContent = 'Verbindungsfehler';
errorDiv.classList.remove('hidden');
}
});
async function logout() {
await fetch('api.php?action=logout', { method: 'POST' });
location.reload();
}
// === Tabs ===
function showTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-blue-600', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
});
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-600');
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-gray-500');
document.getElementById('content-albums').classList.add('hidden');
document.getElementById('content-comments').classList.add('hidden');
document.getElementById(`content-${tab}`).classList.remove('hidden');
if (tab === 'comments') {
loadAllComments();
}
}
// === Albums ===
async function loadAlbums() {
const response = await fetch('api.php?action=albums');
const data = await response.json();
renderAlbumsTable(data.albums || []);
}
function renderAlbumsTable(albums) {
const tbody = document.getElementById('albums-table-body');
if (albums.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-8 text-gray-500">Noch keine Alben vorhanden</td></tr>';
return;
}
tbody.innerHTML = albums.map(album => `
<tr class="border-t hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium">${escapeHtml(album.title)}</div>
<div class="text-sm text-gray-500 truncate max-w-xs">${escapeHtml(album.url)}</div>
</td>
<td class="px-4 py-3 text-gray-600">${album.date}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
${album.tags.slice(0, 3).map(tag =>
`<span class="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">${escapeHtml(tag)}</span>`
).join('')}
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
</div>
</td>
<td class="px-4 py-3 text-right">
<button onclick='editAlbum(${JSON.stringify(album).replace(/'/g, "&#39;")})' class="text-blue-600 hover:text-blue-800 mr-2">
<i class="fas fa-edit"></i>
</button>
<button onclick="confirmDelete('album', '${album.id}', '${escapeHtml(album.title)}')" class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadAllTags() {
const response = await fetch('api.php?action=tags');
const data = await response.json();
allTags = data.tags || [];
}
// === Album Form ===
document.getElementById('album-form').addEventListener('submit', async (e) => {
e.preventDefault();
const album = {
csrf: csrfToken,
title: document.getElementById('album-title').value,
url: document.getElementById('album-url').value,
date: document.getElementById('album-date').value,
description: document.getElementById('album-description').value,
tags: currentTags,
thumbnail: document.getElementById('album-thumbnail').value
};
let url = 'api.php?action=album';
let method = 'POST';
if (editingAlbumId) {
album.id = editingAlbumId;
method = 'PUT';
}
try {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album)
});
const data = await response.json();
if (data.success) {
resetForm();
loadAlbums();
loadAllTags();
} else {
alert(data.error || 'Fehler beim Speichern');
}
} catch (err) {
alert('Verbindungsfehler');
}
});
function editAlbum(album) {
editingAlbumId = album.id;
document.getElementById('album-id').value = album.id;
document.getElementById('album-title').value = album.title;
document.getElementById('album-url').value = album.url;
document.getElementById('album-date').value = album.date;
document.getElementById('album-description').value = album.description || '';
document.getElementById('album-thumbnail').value = album.thumbnail || '';
// Tags
currentTags = [...album.tags];
renderTags();
// Thumbnail preview
if (album.thumbnail) {
document.getElementById('thumbnail-preview').innerHTML =
`<img src="${escapeHtml(album.thumbnail)}" class="h-20 rounded">`;
}
document.getElementById('form-title').textContent = 'Album bearbeiten';
document.getElementById('submit-text').textContent = 'Aktualisieren';
// Scroll to form
document.getElementById('album-form').scrollIntoView({ behavior: 'smooth' });
}
function resetForm() {
editingAlbumId = null;
document.getElementById('album-form').reset();
document.getElementById('album-thumbnail').value = '';
document.getElementById('thumbnail-preview').innerHTML = '';
currentTags = [];
renderTags();
document.getElementById('form-title').textContent = 'Neues Album hinzufügen';
document.getElementById('submit-text').textContent = 'Speichern';
}
async function deleteAlbum(id) {
try {
const response = await fetch('api.php?action=album', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, csrf: csrfToken })
});
const data = await response.json();
if (data.success) {
loadAlbums();
} else {
alert(data.error || 'Fehler beim Löschen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Tags ===
function renderTags() {
const container = document.getElementById('tags-container');
const input = document.getElementById('tag-input');
// Remove existing tag items
container.querySelectorAll('.tag-item').forEach(el => el.remove());
// Add tag items before input
currentTags.forEach((tag, index) => {
const span = document.createElement('span');
span.className = 'tag-item';
span.innerHTML = `${escapeHtml(tag)}<button type="button" onclick="removeTag(${index})">&times;</button>`;
container.insertBefore(span, input);
});
}
function removeTag(index) {
currentTags.splice(index, 1);
renderTags();
}
document.getElementById('tag-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const value = e.target.value.trim();
if (value && !currentTags.includes(value)) {
currentTags.push(value);
renderTags();
}
e.target.value = '';
document.getElementById('tag-suggestions').classList.add('hidden');
}
});
document.getElementById('tag-input').addEventListener('input', (e) => {
const value = e.target.value.toLowerCase();
const suggestions = document.getElementById('tag-suggestions');
if (value.length < 1) {
suggestions.classList.add('hidden');
return;
}
const matches = allTags.filter(tag =>
tag.toLowerCase().includes(value) && !currentTags.includes(tag)
).slice(0, 5);
if (matches.length === 0) {
suggestions.classList.add('hidden');
return;
}
suggestions.innerHTML = matches.map(tag =>
`<div onclick="selectTag('${escapeHtml(tag)}')">${escapeHtml(tag)}</div>`
).join('');
suggestions.classList.remove('hidden');
});
function selectTag(tag) {
if (!currentTags.includes(tag)) {
currentTags.push(tag);
renderTags();
}
document.getElementById('tag-input').value = '';
document.getElementById('tag-suggestions').classList.add('hidden');
}
// === Thumbnail Upload ===
async function uploadThumbnail() {
const fileInput = document.getElementById('thumbnail-file');
if (!fileInput.files[0]) {
alert('Bitte wähle zuerst ein Bild aus');
return;
}
const formData = new FormData();
formData.append('thumbnail', fileInput.files[0]);
formData.append('csrf', csrfToken);
try {
const response = await fetch('api.php?action=upload_thumbnail', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
document.getElementById('album-thumbnail').value = data.path;
document.getElementById('thumbnail-preview').innerHTML =
`<img src="${escapeHtml(data.path)}" class="h-20 rounded">`;
fileInput.value = '';
} else {
alert(data.error || 'Upload fehlgeschlagen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Comments ===
async function loadAllComments() {
const albumsResponse = await fetch('api.php?action=albums');
const albumsData = await albumsResponse.json();
const albums = albumsData.albums || [];
const commentsContainer = document.getElementById('comments-list');
commentsContainer.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Lade Kommentare...</p>';
// Kommentare für alle Alben laden
const allComments = [];
for (const album of albums) {
const response = await fetch(`api.php?action=comments&album_id=${album.id}`);
const data = await response.json();
(data.comments || []).forEach(comment => {
comment.albumTitle = album.title;
allComments.push(comment);
});
}
// Nach Datum sortieren
allComments.sort((a, b) => b.created_at.localeCompare(a.created_at));
if (allComments.length === 0) {
commentsContainer.innerHTML = '<p class="text-center text-gray-500 py-8">Noch keine Kommentare vorhanden</p>';
return;
}
commentsContainer.innerHTML = allComments.map(comment => `
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-semibold">${escapeHtml(comment.author)}</span>
<span class="text-gray-400 text-sm ml-2">${formatDateTime(comment.created_at)}</span>
</div>
<button onclick="confirmDelete('comment', '${comment.id}', 'diesen Kommentar')" class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</div>
<p class="text-gray-700 mb-2">${escapeHtml(comment.text)}</p>
<p class="text-sm text-gray-500">
<i class="fas fa-images mr-1"></i>${escapeHtml(comment.albumTitle)}
</p>
</div>
`).join('');
}
async function deleteComment(id) {
try {
const response = await fetch('api.php?action=comment', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, csrf: csrfToken })
});
const data = await response.json();
if (data.success) {
loadAllComments();
} else {
alert(data.error || 'Fehler beim Löschen');
}
} catch (err) {
alert('Verbindungsfehler');
}
}
// === Confirm Modal ===
function confirmDelete(type, id, name) {
document.getElementById('confirm-message').textContent =
`Möchtest du "${name}" wirklich löschen?`;
confirmCallback = () => {
if (type === 'album') {
deleteAlbum(id);
} else if (type === 'comment') {
deleteComment(id);
}
};
document.getElementById('confirm-modal').classList.remove('hidden');
}
function closeConfirm() {
document.getElementById('confirm-modal').classList.add('hidden');
confirmCallback = null;
}
document.getElementById('confirm-btn').addEventListener('click', () => {
if (confirmCallback) {
confirmCallback();
}
closeConfirm();
});
// === Helpers ===
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDateTime(isoStr) {
const date = new Date(isoStr);
return date.toLocaleDateString('de-CH', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// === Init ===
checkAuth();
</script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
<?php
/**
* FamilyAlbums - API Endpunkte
*/
require_once __DIR__ . '/config.php';
session_start();
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// Hilfsfunktion: JSON Response
function json_response(array $data, int $code = 200): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// Hilfsfunktion: Admin-Check
function require_admin(): void {
if (empty($_SESSION['admin_logged_in'])) {
json_response(['error' => 'Nicht autorisiert'], 401);
}
}
// === ALBEN ===
if ($action === 'albums' && $method === 'GET') {
// Alle Alben abrufen (öffentlich)
$data = read_json(ALBUMS_FILE);
$albums = $data['albums'] ?? [];
// Filter: Jahr
if (!empty($_GET['year'])) {
$year = $_GET['year'];
$albums = array_filter($albums, fn($a) => substr($a['date'], 0, 4) === $year);
}
// Filter: Monat
if (!empty($_GET['month'])) {
$month = $_GET['month'];
$albums = array_filter($albums, fn($a) => substr($a['date'], 5, 2) === $month);
}
// Filter: Suche
if (!empty($_GET['search'])) {
$search = mb_strtolower($_GET['search']);
$albums = array_filter($albums, function($a) use ($search) {
$haystack = mb_strtolower($a['title'] . ' ' . $a['description'] . ' ' . implode(' ', $a['tags']));
return str_contains($haystack, $search);
});
}
// Sortierung
$sort = $_GET['sort'] ?? 'newest';
usort($albums, function($a, $b) use ($sort) {
if ($sort === 'oldest') {
return strcmp($a['date'], $b['date']);
}
return strcmp($b['date'], $a['date']); // newest first
});
json_response(['albums' => array_values($albums)]);
}
if ($action === 'album' && $method === 'POST') {
// Album erstellen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
if (empty($input['title']) || empty($input['url']) || empty($input['date'])) {
json_response(['error' => 'Titel, URL und Datum sind Pflichtfelder'], 400);
}
$album = [
'id' => generate_uuid(),
'title' => trim($input['title']),
'url' => trim($input['url']),
'date' => $input['date'],
'tags' => array_map('trim', $input['tags'] ?? []),
'description' => trim($input['description'] ?? ''),
'thumbnail' => $input['thumbnail'] ?? '',
'created_at' => date('c')
];
$data = read_json(ALBUMS_FILE);
$data['albums'][] = $album;
write_json(ALBUMS_FILE, $data);
json_response(['success' => true, 'album' => $album]);
}
if ($action === 'album' && $method === 'PUT') {
// Album bearbeiten (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(ALBUMS_FILE);
$found = false;
foreach ($data['albums'] as &$album) {
if ($album['id'] === $id) {
$album['title'] = trim($input['title'] ?? $album['title']);
$album['url'] = trim($input['url'] ?? $album['url']);
$album['date'] = $input['date'] ?? $album['date'];
$album['tags'] = array_map('trim', $input['tags'] ?? $album['tags']);
$album['description'] = trim($input['description'] ?? $album['description']);
$album['thumbnail'] = $input['thumbnail'] ?? $album['thumbnail'];
$found = true;
break;
}
}
if (!$found) {
json_response(['error' => 'Album nicht gefunden'], 404);
}
write_json(ALBUMS_FILE, $data);
json_response(['success' => true]);
}
if ($action === 'album' && $method === 'DELETE') {
// Album löschen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(ALBUMS_FILE);
$data['albums'] = array_filter($data['albums'], fn($a) => $a['id'] !== $id);
$data['albums'] = array_values($data['albums']);
write_json(ALBUMS_FILE, $data);
// Zugehörige Kommentare löschen
$comments = read_json(COMMENTS_FILE);
$comments['comments'] = array_filter($comments['comments'], fn($c) => $c['album_id'] !== $id);
$comments['comments'] = array_values($comments['comments']);
write_json(COMMENTS_FILE, $comments);
json_response(['success' => true]);
}
// === KOMMENTARE ===
if ($action === 'comments' && $method === 'GET') {
// Kommentare für Album abrufen (öffentlich)
$album_id = $_GET['album_id'] ?? '';
$data = read_json(COMMENTS_FILE);
$comments = array_filter($data['comments'] ?? [], fn($c) => $c['album_id'] === $album_id);
// Nach Datum sortieren (neueste zuerst)
usort($comments, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
json_response(['comments' => array_values($comments)]);
}
if ($action === 'comment' && $method === 'POST') {
// Kommentar erstellen (öffentlich)
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['album_id']) || empty($input['author']) || empty($input['text'])) {
json_response(['error' => 'Album-ID, Name und Text sind Pflichtfelder'], 400);
}
// Honeypot-Check (Spam-Schutz)
if (!empty($input['website'])) {
json_response(['success' => true]); // Fake-Erfolg für Bots
}
// Rate-Limiting: Max 5 Kommentare pro Minute pro IP
$ip = $_SERVER['REMOTE_ADDR'];
$rate_file = DATA_PATH . 'rate_' . md5($ip) . '.json';
$rate_data = read_json($rate_file);
$now = time();
$rate_data['times'] = array_filter($rate_data['times'] ?? [], fn($t) => $t > $now - 60);
if (count($rate_data['times']) >= 5) {
json_response(['error' => 'Zu viele Kommentare. Bitte warte eine Minute.'], 429);
}
$rate_data['times'][] = $now;
write_json($rate_file, $rate_data);
$comment = [
'id' => generate_uuid(),
'album_id' => $input['album_id'],
'author' => trim($input['author']),
'text' => trim($input['text']),
'created_at' => date('c')
];
$data = read_json(COMMENTS_FILE);
$data['comments'][] = $comment;
write_json(COMMENTS_FILE, $data);
json_response(['success' => true, 'comment' => $comment]);
}
if ($action === 'comment' && $method === 'DELETE') {
// Kommentar löschen (Admin)
require_admin();
$input = json_decode(file_get_contents('php://input'), true);
if (!csrf_validate($input['csrf'] ?? '')) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
$id = $input['id'] ?? '';
$data = read_json(COMMENTS_FILE);
$data['comments'] = array_filter($data['comments'], fn($c) => $c['id'] !== $id);
$data['comments'] = array_values($data['comments']);
write_json(COMMENTS_FILE, $data);
json_response(['success' => true]);
}
// === TAGS ===
if ($action === 'tags' && $method === 'GET') {
// Alle verwendeten Tags abrufen (für Vorschläge)
$data = read_json(ALBUMS_FILE);
$tags = [];
foreach ($data['albums'] ?? [] as $album) {
foreach ($album['tags'] ?? [] as $tag) {
$tags[$tag] = ($tags[$tag] ?? 0) + 1;
}
}
arsort($tags);
json_response(['tags' => array_keys($tags)]);
}
// === JAHRE/MONATE ===
if ($action === 'dates' && $method === 'GET') {
// Verfügbare Jahre und Monate
$data = read_json(ALBUMS_FILE);
$years = [];
foreach ($data['albums'] ?? [] as $album) {
$year = substr($album['date'], 0, 4);
$month = substr($album['date'], 5, 2);
if (!isset($years[$year])) {
$years[$year] = [];
}
if (!in_array($month, $years[$year])) {
$years[$year][] = $month;
}
}
// Sortieren
krsort($years);
foreach ($years as &$months) {
sort($months);
}
json_response(['dates' => $years]);
}
// === AUTH ===
if ($action === 'login' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$password = $input['password'] ?? '';
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
$_SESSION['admin_logged_in'] = true;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
json_response(['success' => true, 'csrf' => $_SESSION['csrf_token']]);
}
// Verzögerung gegen Brute-Force
sleep(1);
json_response(['error' => 'Falsches Passwort'], 401);
}
if ($action === 'logout' && $method === 'POST') {
session_destroy();
json_response(['success' => true]);
}
if ($action === 'check_auth' && $method === 'GET') {
json_response([
'authenticated' => !empty($_SESSION['admin_logged_in']),
'csrf' => $_SESSION['csrf_token'] ?? ''
]);
}
// === THUMBNAIL UPLOAD ===
if ($action === 'upload_thumbnail' && $method === 'POST') {
require_admin();
if (empty($_POST['csrf']) || !csrf_validate($_POST['csrf'])) {
json_response(['error' => 'Ungültiges CSRF-Token'], 403);
}
if (empty($_FILES['thumbnail']) || $_FILES['thumbnail']['error'] !== UPLOAD_ERR_OK) {
json_response(['error' => 'Kein Bild hochgeladen'], 400);
}
$file = $_FILES['thumbnail'];
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($file['type'], $allowed)) {
json_response(['error' => 'Nur JPG, PNG, GIF und WebP erlaubt'], 400);
}
if ($file['size'] > 5 * 1024 * 1024) {
json_response(['error' => 'Maximale Dateigrösse: 5MB'], 400);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = generate_uuid() . '.' . $ext;
$path = THUMBNAIL_PATH . $filename;
if (!move_uploaded_file($file['tmp_name'], $path)) {
json_response(['error' => 'Upload fehlgeschlagen'], 500);
}
json_response(['success' => true, 'path' => THUMBNAIL_URL . $filename]);
}
// Unbekannte Aktion
json_response(['error' => 'Unbekannte Aktion'], 404);
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* FamilyAlbums - Konfiguration
*
* WICHTIG: Nach erster Installation Passwort ändern!
* Neuen Hash generieren: php -r "echo password_hash('deinPasswort', PASSWORD_DEFAULT);"
*/
// Standard-Passwort: "familie2024" - BITTE ÄNDERN!
define('ADMIN_PASSWORD_HASH', '$2y$10$YxQx8B7GkDqNmPrC4VzKH.qN4tQ8WvX5kF7mZ3hJ9aE1bC2dR6uYO');
define('SITE_TITLE', 'Familien-Fotoalben');
define('DATA_PATH', __DIR__ . '/data/');
define('THUMBNAIL_PATH', __DIR__ . '/thumbnails/');
define('THUMBNAIL_URL', 'thumbnails/');
define('ALBUMS_FILE', DATA_PATH . 'albums.json');
define('COMMENTS_FILE', DATA_PATH . 'comments.json');
// Session-Einstellungen
define('SESSION_LIFETIME', 3600); // 1 Stunde
// Zeitzone
date_default_timezone_set('Europe/Zurich');
/**
* JSON-Datei lesen
*/
function read_json(string $file): array {
if (!file_exists($file)) {
return [];
}
$content = file_get_contents($file);
return json_decode($content, true) ?? [];
}
/**
* JSON-Datei schreiben
*/
function write_json(string $file, array $data): bool {
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
return file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) !== false;
}
/**
* UUID generieren
*/
function generate_uuid(): string {
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
/**
* XSS-sichere Ausgabe
*/
function e(string $str): string {
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
/**
* CSRF-Token generieren
*/
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* CSRF-Token validieren
*/
function csrf_validate(string $token): bool {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
// Initialisiere Daten-Dateien falls nicht vorhanden
if (!file_exists(ALBUMS_FILE)) {
write_json(ALBUMS_FILE, ['albums' => []]);
}
if (!file_exists(COMMENTS_FILE)) {
write_json(COMMENTS_FILE, ['comments' => []]);
}
+8
View File
@@ -0,0 +1,8 @@
# Deny access to all files in this directory
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+14
View File
@@ -0,0 +1,14 @@
{
"albums": [
{
"id": "demo-001",
"title": "Weihnachten 2024",
"url": "https://nextcloud.example.com/apps/photos/public/demo",
"date": "2024-12-25",
"tags": ["weihnachten", "familie", "2024"],
"description": "Bescherung und Festessen bei der Familie",
"thumbnail": "",
"created_at": "2024-12-26T10:00:00+01:00"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"comments": []
}
+449
View File
@@ -0,0 +1,449 @@
<?php
/**
* FamilyAlbums - Öffentliche Ansicht
*/
require_once __DIR__ . '/config.php';
$pageTitle = SITE_TITLE;
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle) ?></title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
.album-card:hover { transform: translateY(-4px); }
.tag { transition: all 0.2s; }
.tag:hover { transform: scale(1.05); }
.modal { transition: opacity 0.3s; }
.modal.hidden { opacity: 0; pointer-events: none; }
.gradient-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- Header -->
<header class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-lg">
<div class="container mx-auto px-4 py-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<h1 class="text-2xl md:text-3xl font-bold">
<i class="fas fa-images mr-2"></i><?= e($pageTitle) ?>
</h1>
<a href="admin.php" class="text-white/80 hover:text-white text-sm">
<i class="fas fa-lock mr-1"></i>Admin
</a>
</div>
</div>
</header>
<!-- Filter-Bereich -->
<div class="bg-white shadow-md sticky top-0 z-10">
<div class="container mx-auto px-4 py-4">
<div class="flex flex-col md:flex-row gap-4">
<!-- Suche -->
<div class="flex-1">
<div class="relative">
<input type="text" id="search" placeholder="Album suchen..."
class="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<i class="fas fa-search absolute left-3 top-3 text-gray-400"></i>
</div>
</div>
<!-- Jahr -->
<select id="filter-year" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="">Alle Jahre</option>
</select>
<!-- Monat -->
<select id="filter-month" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" disabled>
<option value="">Alle Monate</option>
</select>
<!-- Sortierung -->
<select id="sort" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
</div>
</div>
<!-- Album-Grid -->
<main class="container mx-auto px-4 py-8">
<div id="albums-container" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<!-- Alben werden per JS geladen -->
</div>
<div id="no-results" class="hidden text-center py-12 text-gray-500">
<i class="fas fa-search text-4xl mb-4"></i>
<p class="text-xl">Keine Alben gefunden</p>
</div>
<div id="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-blue-500"></i>
</div>
</main>
<!-- Album-Detail Modal -->
<div id="album-modal" class="modal hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<h2 id="modal-title" class="text-2xl font-bold text-gray-800"></h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div id="modal-thumbnail" class="mb-4 rounded-lg overflow-hidden"></div>
<p id="modal-date" class="text-gray-500 mb-2"></p>
<p id="modal-description" class="text-gray-700 mb-4"></p>
<div id="modal-tags" class="flex flex-wrap gap-2 mb-6"></div>
<a id="modal-link" href="#" target="_blank"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition mb-6">
<i class="fas fa-external-link-alt mr-2"></i>Album öffnen
</a>
<!-- Kommentare -->
<div class="border-t pt-6">
<h3 class="text-lg font-semibold mb-4">
<i class="fas fa-comments mr-2"></i>Kommentare
</h3>
<div id="comments-list" class="space-y-4 mb-6"></div>
<!-- Kommentar-Formular -->
<form id="comment-form" class="bg-gray-50 p-4 rounded-lg">
<input type="hidden" id="comment-album-id">
<!-- Honeypot -->
<input type="text" name="website" id="comment-website" class="hidden" tabindex="-1" autocomplete="off">
<div class="mb-3">
<input type="text" id="comment-author" placeholder="Dein Name" required
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-3">
<textarea id="comment-text" placeholder="Dein Kommentar..." required rows="3"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition">
<i class="fas fa-paper-plane mr-2"></i>Absenden
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-gray-800 text-white py-6 mt-12">
<div class="container mx-auto px-4 text-center">
<p>&copy; <?= date('Y') ?> <?= e($pageTitle) ?></p>
</div>
</footer>
<script>
// === State ===
let allDates = {};
let currentAlbumId = null;
let debounceTimer = null;
// === Monatsnamen ===
const monthNames = {
'01': 'Januar', '02': 'Februar', '03': 'März', '04': 'April',
'05': 'Mai', '06': 'Juni', '07': 'Juli', '08': 'August',
'09': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember'
};
// === Helpers ===
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${parseInt(day)}. ${monthNames[month]} ${year}`;
}
function formatDateTime(isoStr) {
const date = new Date(isoStr);
return date.toLocaleDateString('de-CH', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// === API Calls ===
async function fetchAlbums() {
const params = new URLSearchParams();
const year = document.getElementById('filter-year').value;
const month = document.getElementById('filter-month').value;
const search = document.getElementById('search').value;
const sort = document.getElementById('sort').value;
if (year) params.append('year', year);
if (month) params.append('month', month);
if (search) params.append('search', search);
params.append('sort', sort);
const response = await fetch(`api.php?action=albums&${params}`);
return response.json();
}
async function fetchDates() {
const response = await fetch('api.php?action=dates');
return response.json();
}
async function fetchComments(albumId) {
const response = await fetch(`api.php?action=comments&album_id=${encodeURIComponent(albumId)}`);
return response.json();
}
async function postComment(albumId, author, text, website) {
const response = await fetch('api.php?action=comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId, author, text, website })
});
return response.json();
}
// === Rendering ===
function renderAlbums(albums) {
const container = document.getElementById('albums-container');
const noResults = document.getElementById('no-results');
const loading = document.getElementById('loading');
loading.classList.add('hidden');
if (albums.length === 0) {
container.innerHTML = '';
noResults.classList.remove('hidden');
return;
}
noResults.classList.add('hidden');
container.innerHTML = albums.map(album => `
<div class="album-card bg-white rounded-xl shadow-md overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-xl"
data-album='${JSON.stringify(album).replace(/'/g, "&#39;")}'
onclick="openModalFromCard(this)">
<div class="aspect-video gradient-placeholder flex items-center justify-center">
${album.thumbnail
? `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<i class=\\'fas fa-images text-4xl text-white/50\\'></i>'">`
: `<i class="fas fa-images text-4xl text-white/50"></i>`
}
</div>
<div class="p-4">
<h3 class="font-semibold text-lg text-gray-800 mb-1 line-clamp-2">${escapeHtml(album.title)}</h3>
<p class="text-gray-500 text-sm mb-3">
<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}
</p>
<div class="flex flex-wrap gap-1">
${album.tags.slice(0, 3).map(tag => `
<span class="tag bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full">${escapeHtml(tag)}</span>
`).join('')}
${album.tags.length > 3 ? `<span class="text-gray-400 text-xs">+${album.tags.length - 3}</span>` : ''}
</div>
</div>
</div>
`).join('');
}
function renderDateFilters(dates) {
allDates = dates;
const yearSelect = document.getElementById('filter-year');
yearSelect.innerHTML = '<option value="">Alle Jahre</option>' +
Object.keys(dates).map(year => `<option value="${year}">${year}</option>`).join('');
}
function updateMonthFilter() {
const year = document.getElementById('filter-year').value;
const monthSelect = document.getElementById('filter-month');
if (!year || !allDates[year]) {
monthSelect.innerHTML = '<option value="">Alle Monate</option>';
monthSelect.disabled = true;
return;
}
monthSelect.disabled = false;
monthSelect.innerHTML = '<option value="">Alle Monate</option>' +
allDates[year].map(month => `<option value="${month}">${monthNames[month]}</option>`).join('');
}
function renderComments(comments) {
const container = document.getElementById('comments-list');
if (comments.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center italic">Noch keine Kommentare. Sei der Erste!</p>';
return;
}
container.innerHTML = comments.map(comment => `
<div class="bg-white p-3 rounded-lg border">
<div class="flex justify-between items-start mb-1">
<span class="font-semibold text-gray-800">${escapeHtml(comment.author)}</span>
<span class="text-gray-400 text-xs">${formatDateTime(comment.created_at)}</span>
</div>
<p class="text-gray-700">${escapeHtml(comment.text)}</p>
</div>
`).join('');
}
// === Modal ===
function openModalFromCard(element) {
const album = JSON.parse(element.dataset.album);
openModal(album.id, album);
}
function openModal(id, album) {
currentAlbumId = id;
document.getElementById('modal-title').textContent = album.title;
document.getElementById('modal-date').innerHTML = `<i class="fas fa-calendar mr-1"></i>${formatDate(album.date)}`;
document.getElementById('modal-description').textContent = album.description || 'Keine Beschreibung';
document.getElementById('modal-link').href = album.url;
document.getElementById('comment-album-id').value = id;
// Thumbnail
const thumbnailContainer = document.getElementById('modal-thumbnail');
if (album.thumbnail) {
thumbnailContainer.innerHTML = `<img src="${escapeHtml(album.thumbnail)}" alt="${escapeHtml(album.title)}" class="w-full max-h-64 object-cover">`;
} else {
thumbnailContainer.innerHTML = '';
}
// Tags
document.getElementById('modal-tags').innerHTML = album.tags.map(tag =>
`<span class="bg-blue-100 text-blue-700 text-sm px-3 py-1 rounded-full">${escapeHtml(tag)}</span>`
).join('');
// Modal anzeigen
document.getElementById('album-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Kommentare laden
loadComments(id);
}
function closeModal() {
document.getElementById('album-modal').classList.add('hidden');
document.body.style.overflow = '';
currentAlbumId = null;
}
async function loadComments(albumId) {
document.getElementById('comments-list').innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i></p>';
const data = await fetchComments(albumId);
renderComments(data.comments || []);
}
// === Event Listeners ===
document.getElementById('search').addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
}, 300);
});
document.getElementById('filter-year').addEventListener('change', async () => {
updateMonthFilter();
document.getElementById('filter-month').value = '';
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('filter-month').addEventListener('change', async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('sort').addEventListener('change', async () => {
const data = await fetchAlbums();
renderAlbums(data.albums || []);
});
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const albumId = document.getElementById('comment-album-id').value;
const author = document.getElementById('comment-author').value.trim();
const text = document.getElementById('comment-text').value.trim();
const website = document.getElementById('comment-website').value;
if (!author || !text) return;
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Senden...';
try {
const result = await postComment(albumId, author, text, website);
if (result.error) {
alert(result.error);
} else {
document.getElementById('comment-text').value = '';
await loadComments(albumId);
}
} catch (err) {
alert('Fehler beim Senden des Kommentars');
}
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Absenden';
});
// Modal schliessen bei Klick ausserhalb
document.getElementById('album-modal').addEventListener('click', (e) => {
if (e.target.id === 'album-modal') {
closeModal();
}
});
// Modal schliessen mit Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
// === Init ===
async function init() {
try {
const [albumsData, datesData] = await Promise.all([
fetchAlbums(),
fetchDates()
]);
renderAlbums(albumsData.albums || []);
renderDateFilters(datesData.dates || {});
} catch (err) {
console.error('Fehler beim Laden:', err);
document.getElementById('loading').innerHTML =
'<p class="text-red-500"><i class="fas fa-exclamation-triangle mr-2"></i>Fehler beim Laden der Alben</p>';
}
}
init();
</script>
</body>
</html>