import SwiftUI /// Gekoppelde apparaten + code/QR-goedkeuring (alleen hoofdprofiel). struct DevicesScreen: View { @EnvironmentObject private var app: AppModel @State private var devices: [LinkedDevice] = [] @State private var loading = true @State private var error: String? @State private var revokeTarget: LinkedDevice? @State private var manualCode = "" @State private var approving = false @State private var approveMessage: String? @State private var approveError: String? var body: some View { List { Section { Text("Scan de QR op je TV of voer de code hieronder in om een apparaat te koppelen.") .font(McFonts.caption) .foregroundStyle(McColors.textDim) .listRowBackground(McColors.bgElevated) HStack(spacing: 10) { TextField("ABCD-EFGH", text: $manualCode) .textInputAutocapitalization(.characters) .autocorrectionDisabled() .font(.system(size: 18, weight: .semibold, design: .monospaced)) .foregroundStyle(McColors.text) .onChange(of: manualCode) { _, value in let normalized = AppModel.normalizeLinkCode(value) if normalized != value { manualCode = normalized } } Button { Task { await approve(manualCode) } } label: { if approving { ProgressView().tint(.white) } else { Text("Koppel") .font(McFonts.headline) } } .buttonStyle(McPrimaryButtonStyle()) .disabled(approving || AppModel.normalizeLinkCode(manualCode).count < 8) } .listRowBackground(McColors.bgElevated) if let approveMessage { Text(approveMessage) .font(McFonts.caption) .foregroundStyle(McColors.success) .listRowBackground(McColors.bgElevated) } if let approveError { Text(approveError) .font(McFonts.caption) .foregroundStyle(McColors.danger) .listRowBackground(McColors.bgElevated) } } header: { Text("Nieuw apparaat") } Section("Gekoppelde apparaten") { if loading && devices.isEmpty { HStack { Spacer() ProgressView().tint(.white) Spacer() } .listRowBackground(McColors.bgElevated) } else if let error, devices.isEmpty { Text(error) .foregroundStyle(McColors.danger) .listRowBackground(McColors.bgElevated) } else if devices.isEmpty { Text("Nog geen apparaten.") .foregroundStyle(McColors.textDim) .listRowBackground(McColors.bgElevated) } else { ForEach(devices) { device in HStack(alignment: .top, spacing: 12) { Image(systemName: platformIcon(device.platform)) .font(.system(size: 22)) .foregroundStyle(McColors.accent) .frame(width: 28) VStack(alignment: .leading, spacing: 4) { HStack { Text(device.name) .font(McFonts.headline) .foregroundStyle(McColors.text) if device.current == true { Text("Dit apparaat") .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundStyle(McColors.accent) .padding(.horizontal, 8) .padding(.vertical, 2) .background(McColors.accentSoft, in: Capsule()) } } Text(platformLabel(device.platform)) .font(McFonts.caption) .foregroundStyle(McColors.textDim) if let seen = formatDate(device.lastSeenAt) { Text("Laatst gezien: \(seen)") .font(McFonts.caption) .foregroundStyle(McColors.textDim) } } Spacer(minLength: 8) if device.current != true { Button("Verwijder", role: .destructive) { revokeTarget = device } .font(McFonts.caption) } } .listRowBackground(McColors.bgElevated) } } } } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "APPARATEN") } } .toolbarBackground(McColors.bg, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .task { await reload() } .refreshable { await reload() } .onChange(of: app.pendingApproveCode) { _, code in guard let code, !code.isEmpty else { return } manualCode = code } .onAppear { if let code = app.pendingApproveCode, !code.isEmpty { manualCode = code } } .confirmationDialog( "Apparaat verwijderen?", isPresented: Binding( get: { revokeTarget != nil }, set: { if !$0 { revokeTarget = nil } } ), titleVisibility: .visible ) { Button("Verwijderen", role: .destructive) { if let target = revokeTarget { Task { await revoke(target) } } } Button("Annuleren", role: .cancel) { revokeTarget = nil } } message: { if let name = revokeTarget?.name { Text("\(name) moet opnieuw worden gekoppeld om VMC te gebruiken.") } } } private func reload() async { loading = true error = nil do { devices = try await app.api.listDevices() loading = false } catch { self.error = error.localizedDescription loading = false } } private func approve(_ raw: String) async { let code = AppModel.normalizeLinkCode(raw) guard code.count >= 8 else { return } approving = true approveError = nil approveMessage = nil do { let result = try await app.api.approveDeviceCode(code) let name = result.deviceName ?? "Apparaat" approveMessage = "\(name) is gekoppeld." app.clearPendingApprove() manualCode = "" await reload() approving = false } catch { approveError = error.localizedDescription approving = false } } private func revoke(_ device: LinkedDevice) async { revokeTarget = nil do { try await app.api.revokeDevice(deviceId: device.id) await reload() } catch { self.error = error.localizedDescription } } private func platformIcon(_ platform: String?) -> String { switch (platform ?? "").lowercased() { case "android_tv", "android-tv", "tv": return "tv" case "android", "android_mobile": return "smartphone" case "ios", "iphone", "ipad": return "iphone" case "tizen": return "tv" default: return "laptopcomputer" } } private func platformLabel(_ platform: String?) -> String { switch (platform ?? "").lowercased() { case "android_tv", "android-tv": return "Android TV" case "android", "android_mobile": return "Android" case "ios": return "iOS" case "tizen": return "Samsung TV" default: return platform?.isEmpty == false ? platform! : "Apparaat" } } private func formatDate(_ iso: String?) -> String? { guard let iso, !iso.isEmpty else { return nil } let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var date = isoFormatter.date(from: iso) if date == nil { isoFormatter.formatOptions = [.withInternetDateTime] date = isoFormatter.date(from: iso) } guard let date else { return iso } let out = DateFormatter() out.locale = Locale(identifier: "nl_NL") out.dateStyle = .medium out.timeStyle = .short return out.string(from: date) } } /// Sheet wanneer QR de app opent terwijl je al in de app zit. struct DeviceApproveSheet: View { @EnvironmentObject private var app: AppModel let code: String @State private var busy = false @State private var error: String? @State private var doneName: String? var body: some View { NavigationStack { VStack(spacing: 20) { Image(systemName: "tv.badge.checkmark") .font(.system(size: 48)) .foregroundStyle(McColors.accent) Text("Apparaat koppelen?") .font(McFonts.title) .foregroundStyle(McColors.text) Text("Code \(code)\nBevestig om dit apparaat aan jouw account te koppelen.") .font(McFonts.body) .foregroundStyle(McColors.textDim) .multilineTextAlignment(.center) if let doneName { Text("\(doneName) is gekoppeld.") .foregroundStyle(McColors.success) } if let error { Text(error).foregroundStyle(McColors.danger).multilineTextAlignment(.center) } Spacer() if doneName == nil { Button { Task { await approve() } } label: { if busy { ProgressView().tint(.white) } else { Text("Goedkeuren") } } .buttonStyle(McPrimaryButtonStyle()) .disabled(busy || !app.canManageProfiles) if !app.canManageProfiles { Text("Schakel naar het hoofdprofiel om apparaten te koppelen.") .font(McFonts.caption) .foregroundStyle(McColors.textDim) .multilineTextAlignment(.center) } } Button("Sluiten") { app.clearPendingApprove() } .foregroundStyle(McColors.textDim) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.bg.ignoresSafeArea()) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "KOPPELEN") } } } } private func approve() async { busy = true error = nil do { let result = try await app.api.approveDeviceCode(code) doneName = result.deviceName ?? "Apparaat" busy = false try? await Task.sleep(nanoseconds: 900_000_000) app.clearPendingApprove() } catch { self.error = error.localizedDescription busy = false } } }