Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
13 KiB
Swift
324 lines
13 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|