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.
377 lines
14 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|