import SwiftUI /// Post-Login Hub: zeigt User-Info + gebundene Geräte + Add-Device-Picker. /// Limit erreicht → Add-Buttons disabled, User muss erst Release anfordern. struct DeviceHubView: View { @Environment(WizardModel.self) private var model @State private var devices: [MagicDevice] = [] @State private var isLoading = false @State private var errorMessage: String? @State private var actionInFlight = false /// Hard-Limit muss synchron zum Backend (MAGIC_DEVICE_LIMIT) sein. private let deviceLimit = 5 private var atLimit: Bool { devices.count >= deviceLimit } var body: some View { VStack(spacing: 0) { header Divider() content } .frame(minWidth: 720, minHeight: 540) .task { await loadDevices() } } @ViewBuilder private var header: some View { HStack(spacing: 12) { Image(systemName: "shield.lefthalf.filled") .font(.title) .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 2) { Text("ReBreak Magic") .font(.title2.bold()) if let email = model.authSession?.email { Text(email) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Text("\(devices.count) / \(deviceLimit) Geräte") .font(.caption.monospacedDigit()) .padding(.horizontal, 10) .padding(.vertical, 5) .background(atLimit ? Color.orange.opacity(0.15) : Color.blue.opacity(0.1)) .foregroundStyle(atLimit ? Color.orange : Color.blue) .clipShape(Capsule()) Button { Task { await model.handleLogout() } } label: { Image(systemName: "rectangle.portrait.and.arrow.right") .help("Abmelden") } .buttonStyle(.borderless) } .padding(.horizontal, 24) .padding(.vertical, 16) } @ViewBuilder private var content: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { addDeviceSection devicesSection } .padding(24) } } @ViewBuilder private var addDeviceSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Neues Gerät hinzufügen") .font(.headline) Spacer() if atLimit { Label("Limit erreicht — bitte unten ein Gerät freigeben", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } } HStack(spacing: 12) { addDeviceButton( title: "iPhone / iPad", subtitle: "Supervised + MDM + ReBreak-App", icon: "iphone", primary: true ) { model.startIOSFlow() } addDeviceButton( title: "Mac", subtitle: "DNS-Filter-Profil installieren", icon: "desktopcomputer", primary: false ) { model.startMacFlow() } } .disabled(atLimit) .opacity(atLimit ? 0.5 : 1.0) } } @ViewBuilder private func addDeviceButton( title: String, subtitle: String, icon: String, primary: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 12) { Image(systemName: icon) .font(.title2) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(title) .font(.headline) Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .padding(14) .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 10) .fill(primary ? Color.blue.opacity(0.08) : Color(nsColor: .controlBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 10) .strokeBorder(primary ? Color.blue.opacity(0.3) : Color.gray.opacity(0.2), lineWidth: 1) ) } .buttonStyle(.plain) } @ViewBuilder private var devicesSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Registrierte Geräte") .font(.headline) Spacer() Button { Task { await loadDevices() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .help("Aktualisieren") .disabled(isLoading) } if let error = errorMessage { Label(error, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.red) .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(Color.red.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 8)) } if isLoading && devices.isEmpty { HStack { ProgressView().controlSize(.small) Text("Lade Geräte…").font(.caption).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 120) } else if devices.isEmpty { emptyState } else { VStack(spacing: 8) { ForEach(devices) { device in HubDeviceRow(device: device) { action in await handleAction(action, device: device) } } } } } } @ViewBuilder private var emptyState: some View { VStack(spacing: 10) { Image(systemName: "checkmark.shield") .font(.system(size: 42)) .foregroundStyle(.secondary) Text("Noch keine Geräte registriert") .font(.callout.bold()) Text("Wähle oben „iPhone / iPad“ oder „Mac“ um anzufangen.") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 160) .padding() .background(Color(nsColor: .controlBackgroundColor).opacity(0.5)) .clipShape(RoundedRectangle(cornerRadius: 10)) } private func loadDevices() async { isLoading = true errorMessage = nil defer { isLoading = false } do { devices = try await MagicAPIClient.shared.listDevices() } catch { errorMessage = error.localizedDescription } } private func handleAction(_ action: HubDeviceAction, device: MagicDevice) async { guard !actionInFlight else { return } actionInFlight = true defer { actionInFlight = false } do { switch action { case .requestRelease: _ = try await MagicAPIClient.shared.requestRelease(deviceId: device.deviceId) case .cancelRelease: try await MagicAPIClient.shared.cancelRelease(deviceId: device.deviceId) } await loadDevices() } catch { errorMessage = error.localizedDescription } } } // MARK: - Row private enum HubDeviceAction { case requestRelease case cancelRelease } private struct HubDeviceRow: View { let device: MagicDevice let onAction: (HubDeviceAction) async -> Void @State private var timeRemaining: String = "" @State private var timer: Timer? @State private var confirmingRelease = false private var platformIcon: String { let m = (device.model ?? "").lowercased() let h = device.hostname.lowercased() if m.contains("iphone") || h.contains("iphone") { return "iphone" } if m.contains("ipad") || h.contains("ipad") { return "ipad" } if m.contains("mac") || h.contains("mac") || h.contains("book") { return "desktopcomputer" } return "shield" } var body: some View { HStack(spacing: 14) { Image(systemName: platformIcon) .font(.title2) .foregroundStyle(.blue) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(device.hostname) .font(.callout.bold()) if device.resolvedSource == .protected { Text("Native-App") .font(.caption2.bold()) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.gray.opacity(0.15)) .foregroundStyle(.secondary) .clipShape(Capsule()) } } HStack(spacing: 8) { if let model = device.model { Text(model).font(.caption2).foregroundStyle(.secondary) } if let os = device.osVersion { Text("·").font(.caption2).foregroundStyle(.tertiary) Text(os).font(.caption2).foregroundStyle(.secondary) } } } Spacer() if device.resolvedSource == .protected { Text("Verwaltung in der ReBreak-App") .font(.caption2) .foregroundStyle(.tertiary) } else if device.isReleasing { VStack(alignment: .trailing, spacing: 2) { Label("Freigabe läuft", systemImage: "hourglass") .font(.caption2.bold()) .foregroundStyle(.orange) if !timeRemaining.isEmpty { Text(timeRemaining).font(.caption2.monospacedDigit()).foregroundStyle(.secondary) } } Button("Abbrechen") { Task { await onAction(.cancelRelease) } } .buttonStyle(.borderless) .font(.caption) } else { Button(role: .destructive) { confirmingRelease = true } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help("Gerät freigeben") .confirmationDialog( "Gerät freigeben?", isPresented: $confirmingRelease, titleVisibility: .visible ) { Button("Freigabe anfordern", role: .destructive) { Task { await onAction(.requestRelease) } } Button("Abbrechen", role: .cancel) { } } message: { Text("Auf „\(device.hostname)“ muss die Freigabe bestätigt werden. Anschließend startet ein 24h-Cooldown bevor der Slot frei wird.") } } } .padding(.horizontal, 14) .padding(.vertical, 10) .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 8)) .onAppear { updateTimeRemaining() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in updateTimeRemaining() } } .onDisappear { timer?.invalidate() } } private func updateTimeRemaining() { guard let releaseDate = device.releaseDate else { timeRemaining = "" return } let remaining = releaseDate.timeIntervalSince(Date()) if remaining <= 0 { timeRemaining = "läuft ab…" return } let hours = Int(remaining) / 3600 let mins = (Int(remaining) % 3600) / 60 let secs = Int(remaining) % 60 if hours > 0 { timeRemaining = String(format: "%dh %02dm", hours, mins) } else { timeRemaining = String(format: "%02d:%02d", mins, secs) } } } #Preview { DeviceHubView() .environment(WizardModel()) }