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