stremio/apps/ios/VMCTV/UI/ManageProfilesView.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

377 lines
14 KiB
Swift

import SwiftUI
/// Owner/admin: profiles list + create/edit/delete on Apple TV.
struct ManageProfilesView: View {
@EnvironmentObject private var app: AppModel
@State private var profiles: [ViewerProfile] = []
@State private var canManage = false
@State private var error: String?
@State private var loading = true
@State private var editing: ViewerProfile?
@State private var showCreate = false
private let cols = [GridItem(.adaptive(minimum: 220, maximum: 260), spacing: 28)]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
Text("Profielen beheren")
.font(TvFonts.title)
.foregroundStyle(TvColors.text)
Text("Voeg profielen toe, wijzig avatar/PIN of verwijder (niet het hoofdprofiel).")
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
if loading {
ProgressView().tint(.white).padding(.top, 40)
} 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: 28) {
ForEach(profiles) { profile in
Button {
editing = profile
} label: {
VStack(spacing: 14) {
Text(ProfileAvatars.emoji(for: profile.avatarId))
.font(.system(size: 64))
.frame(width: 140, height: 140)
.background(TvColors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
Text(profile.name)
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
.lineLimit(1)
Text(profile.isOwner == true ? "Hoofdprofiel" : (profile.isKids == true ? "Kids" : "Profiel"))
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(FocusableCardStyle())
}
if canManage && profiles.count < 8 {
Button {
showCreate = true
} label: {
VStack(spacing: 14) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 56))
.foregroundStyle(TvColors.accent)
.frame(width: 140, height: 140)
.background(TvColors.surface.opacity(0.6), in: RoundedRectangle(cornerRadius: 24, style: .continuous))
Text("Toevoegen")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
Text(" ")
.font(TvFonts.caption)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(FocusableCardStyle())
}
}
.focusSection()
}
}
.padding(.horizontal, 56)
.padding(.bottom, 60)
}
.background(TvColors.bg.ignoresSafeArea())
.task { await load() }
.sheet(item: $editing) { profile in
EditProfileSheet(profile: profile, canManage: canManage) {
editing = nil
Task { await load() }
}
.environmentObject(app)
}
.sheet(isPresented: $showCreate) {
CreateProfileSheetTV {
showCreate = false
Task { await load() }
}
.environmentObject(app)
}
}
private func load() async {
loading = true
error = nil
do {
let payload = try await app.api.listProfiles()
profiles = payload.profiles
canManage = payload.canManage == true
app.canManageProfiles = canManage
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}
struct CreateProfileSheetTV: View {
@EnvironmentObject private var app: AppModel
let onDone: () -> Void
@State private var name = ""
@State private var avatarId = "fox"
@State private var isKids = false
@State private var pin = ""
@State private var error: String?
@State private var saving = false
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
TextField("Naam", text: $name)
.font(TvFonts.body)
Text("Avatar")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 14)], spacing: 14) {
ForEach(ProfileAvatars.all, id: \.id) { avatar in
Button { avatarId = avatar.id } label: {
Text(avatar.emoji)
.font(.system(size: 36))
.frame(width: 72, height: 72)
.background(
avatarId == avatar.id ? TvColors.accent.opacity(0.3) : TvColors.surface,
in: Circle()
)
.overlay {
Circle().strokeBorder(
avatarId == avatar.id ? TvColors.accent : Color.clear,
lineWidth: 3
)
}
}
.buttonStyle(FocusableCardStyle())
}
}
.focusSection()
Toggle("Kids-profiel", isOn: $isKids)
.tint(TvColors.accent)
.font(TvFonts.body)
TextField("PIN (optioneel, 4 cijfers)", text: $pin)
.font(TvFonts.body)
if let error {
Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger)
}
Button {
Task { await save() }
} label: {
Text(saving ? "Bezig…" : "Aanmaken")
}
.buttonStyle(TvPrimaryButtonStyle())
.disabled(saving || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(40)
}
.background(TvColors.bg)
.navigationTitle("Nieuw profiel")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Sluiten", action: onDone)
}
}
}
}
private func save() async {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let pinDigits = pin.filter(\.isNumber)
if !pin.isEmpty && pinDigits.count != 4 {
error = "PIN moet 4 cijfers zijn"
return
}
saving = true
error = nil
do {
_ = try await app.api.createProfile(
name: trimmed,
avatarId: avatarId,
isKids: isKids,
pin: pinDigits.count == 4 ? pinDigits : nil
)
saving = false
onDone()
} catch {
self.error = error.localizedDescription
saving = false
}
}
}
struct EditProfileSheet: View {
@EnvironmentObject private var app: AppModel
let profile: ViewerProfile
let canManage: Bool
let onDone: () -> Void
@State private var name: String
@State private var avatarId: String
@State private var isKids: Bool
@State private var pin = ""
@State private var clearPin = false
@State private var error: String?
@State private var saving = false
@State private var deleting = false
init(profile: ViewerProfile, canManage: Bool, onDone: @escaping () -> Void) {
self.profile = profile
self.canManage = canManage
self.onDone = onDone
_name = State(initialValue: profile.name)
_avatarId = State(initialValue: profile.avatarId ?? "fox")
_isKids = State(initialValue: profile.isKids == true)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
TextField("Naam", text: $name)
.font(TvFonts.body)
Text("Avatar")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 72), spacing: 14)], spacing: 14) {
ForEach(ProfileAvatars.all, id: \.id) { avatar in
Button { avatarId = avatar.id } label: {
Text(avatar.emoji)
.font(.system(size: 36))
.frame(width: 72, height: 72)
.background(
avatarId == avatar.id ? TvColors.accent.opacity(0.3) : TvColors.surface,
in: Circle()
)
.overlay {
Circle().strokeBorder(
avatarId == avatar.id ? TvColors.accent : Color.clear,
lineWidth: 3
)
}
}
.buttonStyle(FocusableCardStyle())
}
}
.focusSection()
if canManage && profile.isOwner != true {
Toggle("Kids-profiel", isOn: $isKids)
.tint(TvColors.accent)
.font(TvFonts.body)
}
TextField("Nieuwe PIN (leeg = ongewijzigd)", text: $pin)
.font(TvFonts.body)
if profile.hasPin == true {
Toggle("PIN verwijderen", isOn: $clearPin)
.tint(TvColors.accent)
.font(TvFonts.body)
}
if let error {
Text(error).font(TvFonts.caption).foregroundStyle(TvColors.danger)
}
Button {
Task { await save() }
} label: {
Text(saving ? "Opslaan…" : "Opslaan")
}
.buttonStyle(TvPrimaryButtonStyle())
.disabled(saving)
if canManage && profile.isOwner != true {
Button(role: .destructive) {
Task { await remove() }
} label: {
Text(deleting ? "Verwijderen…" : "Profiel verwijderen")
}
.buttonStyle(TvSecondaryButtonStyle())
.disabled(deleting)
}
}
.padding(40)
}
.background(TvColors.bg)
.navigationTitle(profile.name)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Sluiten", action: onDone)
}
}
}
}
private func save() async {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
error = "Naam verplicht"
return
}
let pinDigits = pin.filter(\.isNumber)
if !pin.isEmpty && pinDigits.count != 4 {
error = "PIN moet 4 cijfers zijn"
return
}
saving = true
error = nil
do {
_ = try await app.api.updateProfile(
profileId: profile.id,
name: trimmed,
avatarId: avatarId,
isKids: canManage && profile.isOwner != true ? isKids : nil,
pin: (!clearPin && pinDigits.count == 4) ? pinDigits : nil,
clearPin: clearPin
)
if profile.id == app.store.profileId {
app.store.setProfile(
id: profile.id,
name: trimmed,
avatarId: avatarId,
showAvatar: profile.showsAvatar,
avatarUrl: profile.avatarUrl ?? ""
)
}
saving = false
onDone()
} catch {
self.error = error.localizedDescription
saving = false
}
}
private func remove() async {
deleting = true
error = nil
do {
try await app.api.deleteProfile(profileId: profile.id)
deleting = false
onDone()
} catch {
self.error = error.localizedDescription
deleting = false
}
}
}