stremio/apps/ios/VMC/UI/DevicesScreen.swift
Jos Vooges | STH 6ffeb50733 Add in-app device approve/QR pairing and polish iOS browse.
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>
2026-09-10 03:18:38 +02:00

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
}
}
}