stremio/apps/ios/VMCTV/UI/ProfilesView.swift
Jos Vooges | STH ac951617cb Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.
Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
2026-09-18 23:34:42 +02:00

232 lines
8.7 KiB
Swift

import SwiftUI
struct ProfilesView: View {
@EnvironmentObject private var app: AppModel
@State private var profiles: [ViewerProfile] = []
@State private var error: String?
@State private var pinTarget: ViewerProfile?
@State private var pinDigits = ""
@State private var pinError: String?
@State private var loading = true
@State private var selecting = false
private let cols = [GridItem(.adaptive(minimum: 200, maximum: 240), spacing: 36)]
var body: some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.08, green: 0.02, blue: 0.03), TvColors.bg],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
Text("Wie kijkt er?")
.font(TvFonts.title)
.foregroundStyle(TvColors.text)
.padding(.top, 60)
if loading {
ProgressView().tint(.white).scaleEffect(1.5)
} else if let error, profiles.isEmpty {
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger)
Button("Opnieuw") { Task { await load() } }.buttonStyle(TvPrimaryButtonStyle())
} else {
if let error {
Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger)
}
LazyVGrid(columns: cols, spacing: 40) {
ForEach(profiles) { profile in
Button {
Task { await select(profile) }
} label: {
VStack(spacing: 16) {
Text(ProfileAvatars.emoji(for: profile.avatarId))
.font(.system(size: 72))
.frame(width: 160, height: 160)
.background(
LinearGradient(
colors: [TvColors.surfaceSoft, TvColors.surface],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(TvColors.accent.opacity(0.3), lineWidth: 2)
)
Text(profile.name)
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
.lineLimit(1)
if profile.hasPin == true {
Image(systemName: "lock.fill")
.font(.system(size: 18))
.foregroundStyle(TvColors.textDim)
}
}
}
.buttonStyle(FocusableCardStyle())
.disabled(selecting)
}
}
.padding(.horizontal, 64)
.focusSection()
}
Spacer()
}
if let pinTarget {
PinPadOverlay(
profileName: pinTarget.name,
avatarId: pinTarget.avatarId,
digits: $pinDigits,
error: pinError,
busy: selecting,
onCancel: {
self.pinTarget = nil
pinDigits = ""
pinError = nil
},
onSubmit: { code in
Task { await submitPin(for: pinTarget, code: code) }
}
)
.transition(.opacity)
.zIndex(20)
}
}
.animation(.easeOut(duration: 0.2), value: pinTarget?.id)
.task { await load() }
}
private func load() async {
loading = true
error = nil
do {
let payload = try await app.api.listProfiles()
profiles = payload.profiles
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
private func select(_ profile: ViewerProfile, pin: String? = nil) async {
if profile.hasPin == true, pin == nil {
pinDigits = ""
pinError = nil
pinTarget = profile
return
}
selecting = true
error = nil
do {
let selected = try await app.api.selectProfile(profileId: profile.id, pin: pin)
pinTarget = nil
pinDigits = ""
pinError = nil
selecting = false
app.didSelectProfile(selected)
} catch {
selecting = false
if pinTarget != nil {
pinError = error.localizedDescription
pinDigits = ""
} else {
self.error = error.localizedDescription
}
}
}
private func submitPin(for profile: ViewerProfile, code: String) async {
let digits = code.filter(\.isNumber)
guard digits.count == 4 else {
pinError = "PIN moet 4 cijfers zijn"
return
}
await select(profile, pin: String(digits))
}
}
struct PinPadOverlay: View {
let profileName: String
let avatarId: String?
@Binding var digits: String
let error: String?
let busy: Bool
let onCancel: () -> Void
let onSubmit: (String) -> Void
private let keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"]
var body: some View {
ZStack {
Color.black.opacity(0.72).ignoresSafeArea()
VStack(spacing: 28) {
Text(ProfileAvatars.emoji(for: avatarId))
.font(.system(size: 64))
Text("PIN voor \(profileName)")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
HStack(spacing: 18) {
ForEach(0..<4, id: \.self) { i in
Circle()
.fill(i < digits.count ? TvColors.accent : TvColors.surfaceSoft)
.frame(width: 22, height: 22)
.shadow(color: i < digits.count ? TvColors.accent.opacity(0.5) : .clear, radius: 8)
}
}
if let error {
Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger)
}
LazyVGrid(columns: Array(repeating: GridItem(.fixed(100), spacing: 16), count: 3), spacing: 16) {
ForEach(keys, id: \.self) { key in
if key.isEmpty {
Color.clear.frame(width: 100, height: 72)
} else {
Button { tap(key) } label: {
Text(key)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.foregroundStyle(TvColors.text)
.frame(width: 100, height: 72)
.background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(FocusableCardStyle())
.disabled(busy)
}
}
}
.focusSection()
Button("Annuleer", action: onCancel)
.buttonStyle(TvSecondaryButtonStyle())
if busy {
ProgressView().tint(.white)
}
}
.padding(48)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
}
}
private func tap(_ key: String) {
if key == "⌫" {
if !digits.isEmpty { digits.removeLast() }
return
}
guard digits.count < 4 else { return }
digits.append(key)
if digits.count == 4 {
onSubmit(digits)
}
}
}