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.
232 lines
8.7 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|