import SwiftUI import PhotosUI import UIKit struct SettingsScreen: View { @EnvironmentObject private var app: AppModel var body: some View { List { Section { NavigationLink { EditProfileScreen(profileId: app.store.profileId) } label: { HStack(spacing: 14) { Text(ProfileAvatars.emoji(for: app.store.profileAvatarId)) .font(.system(size: 40)) .frame(width: 64, height: 64) .background( LinearGradient(colors: [McColors.surfaceSoft, McColors.surface], startPoint: .top, endPoint: .bottom), in: Circle() ) VStack(alignment: .leading, spacing: 4) { Text(app.store.profileName ?? "Profiel") .font(McFonts.headline) .foregroundStyle(McColors.text) Text("Naam & avatar wijzigen") .font(McFonts.caption) .foregroundStyle(McColors.textDim) } } } .listRowBackground(McColors.bgElevated) } Section("Bibliotheek") { NavigationLink { ShelfPrefsScreen() } label: { Label("Planken beheren", systemImage: "rectangle.stack") .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) } Section("Profielen") { Button { Task { await app.switchProfile() } } label: { Label("Profiel wisselen", systemImage: "person.2") .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) if app.canManageProfiles { NavigationLink { ManageProfilesScreen() } label: { Label("Profielen beheren", systemImage: "person.crop.circle.badge.plus") .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) NavigationLink { DevicesScreen() } label: { Label("Apparaten", systemImage: "tv.and.hifispeaker.fill") .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) } } Section("Server") { LabeledContent("URL", value: app.store.apiBase) if app.canManageProfiles { Button { Task { await app.logout() } } label: { Label("Server / account wisselen", systemImage: "server.rack") .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) } } if app.canManageProfiles { Section { Button(role: .destructive) { Task { await app.logout() } } label: { Label("Uitloggen", systemImage: "rectangle.portrait.and.arrow.right") } .listRowBackground(McColors.bgElevated) } } Section("Info") { LabeledContent("App", value: "VMC iOS") LabeledContent("Versie", value: "0.5.48") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") } .listRowBackground(McColors.bgElevated) } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { BrandHeader(subtitle: "ACCOUNT") } } .toolbarBackground(McColors.bg, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) } } struct EditProfileScreen: View { @EnvironmentObject private var app: AppModel let profileId: String? @State private var name = "" @State private var avatarId = "fox" @State private var showAvatar = true @State private var hasCustomAvatar = false @State private var remoteAvatarUrl: String? @State private var pin = "" @State private var clearPin = false @State private var clearCustomAvatar = false @State private var pendingImage: UIImage? @State private var pickerItem: PhotosPickerItem? @State private var message: String? @State private var error: String? @State private var saving = false private let cols = [GridItem(.adaptive(minimum: 64), spacing: 12)] var body: some View { Form { Section("Profielfoto") { HStack { Spacer() profilePhotoPreview .frame(width: 96, height: 96) .clipShape(Circle()) Spacer() } Toggle("Profielfoto aan/uit", isOn: $showAvatar) .tint(McColors.accent) PhotosPicker(selection: $pickerItem, matching: .images) { Label("Foto kiezen", systemImage: "photo") } if hasCustomAvatar || pendingImage != nil { Button("Foto verwijderen", role: .destructive) { pendingImage = nil pickerItem = nil clearCustomAvatar = true hasCustomAvatar = false } } } .listRowBackground(McColors.bgElevated) .onChange(of: pickerItem) { _, item in Task { await loadPicked(item) } } Section("Naam") { TextField("Naam", text: $name) .foregroundStyle(McColors.text) } .listRowBackground(McColors.bgElevated) Section("Avatar") { LazyVGrid(columns: cols, spacing: 12) { ForEach(ProfileAvatars.all, id: \.id) { avatar in Button { avatarId = avatar.id } label: { Text(avatar.emoji) .font(.system(size: 28)) .frame(width: 56, height: 56) .background( avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, in: Circle() ) .overlay( Circle().stroke(avatarId == avatar.id ? McColors.accent : Color.clear, lineWidth: 2) ) } .buttonStyle(.plain) } } .padding(.vertical, 4) } .listRowBackground(McColors.bgElevated) Section("PIN (optioneel)") { SecureField("Nieuwe PIN (4 cijfers)", text: $pin) .keyboardType(.numberPad) .foregroundStyle(McColors.text) Toggle("PIN verwijderen", isOn: $clearPin) .tint(McColors.accent) } .listRowBackground(McColors.bgElevated) if let message { Section { Text(message).foregroundStyle(McColors.success) } .listRowBackground(McColors.bgElevated) } if let error { Section { Text(error).foregroundStyle(McColors.danger) } .listRowBackground(McColors.bgElevated) } Section { Button { Task { await save() } } label: { if saving { ProgressView() } else { Text("Opslaan") } } .disabled(saving || name.trimmingCharacters(in: .whitespaces).isEmpty) .listRowBackground(McColors.bgElevated) } } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationTitle("Mijn profiel") .navigationBarTitleDisplayMode(.inline) .task { await load() } } @ViewBuilder private var profilePhotoPreview: some View { if let pendingImage { Image(uiImage: pendingImage) .resizable() .scaledToFill() } else if hasCustomAvatar, let remoteAvatarUrl { AuthenticatedAvatarImage(path: remoteAvatarUrl) } else { Text(ProfileAvatars.emoji(for: avatarId)) .font(.system(size: 44)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.surface) } } private func load() async { name = app.store.profileName ?? "" avatarId = app.store.profileAvatarId ?? "fox" showAvatar = app.store.profileShowAvatar remoteAvatarUrl = app.store.profileAvatarUrl hasCustomAvatar = !(app.store.profileAvatarUrl ?? "").isEmpty if let profileId { if let payload = try? await app.api.listProfiles(), let mine = payload.profiles.first(where: { $0.id == profileId }) { name = mine.name avatarId = mine.avatarId ?? "fox" showAvatar = mine.showsAvatar hasCustomAvatar = mine.hasCustomPhoto remoteAvatarUrl = mine.avatarUrl } } } private func loadPicked(_ item: PhotosPickerItem?) async { guard let item else { return } do { if let data = try await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { pendingImage = image.squared() clearCustomAvatar = false hasCustomAvatar = true } } catch { self.error = error.localizedDescription } } private func save() async { guard let profileId else { return } saving = true error = nil message = nil do { var updated = try await app.api.updateProfile( profileId: profileId, name: name.trimmingCharacters(in: .whitespacesAndNewlines), avatarId: avatarId, showAvatar: showAvatar, clearAvatarImage: clearCustomAvatar && pendingImage == nil, pin: clearPin ? nil : (pin.isEmpty ? nil : pin), clearPin: clearPin ) if let pendingImage, let jpeg = pendingImage.jpegData(compressionQuality: 0.85) { let b64 = jpeg.base64EncodedString() updated = try await app.api.uploadProfileAvatar(profileId: updated.id, imageBase64: b64) } app.store.setProfile( id: updated.id, name: updated.name, avatarId: updated.avatarId, showAvatar: updated.showsAvatar, avatarUrl: updated.avatarUrl ?? "" ) hasCustomAvatar = updated.hasCustomPhoto remoteAvatarUrl = updated.avatarUrl pendingImage = nil clearCustomAvatar = false message = "Opgeslagen" pin = "" clearPin = false saving = false } catch { self.error = error.localizedDescription saving = false } } } struct ManageProfilesScreen: View { @EnvironmentObject private var app: AppModel @State private var profiles: [ViewerProfile] = [] @State private var canManage = false @State private var error: String? @State private var showCreate = false var body: some View { List { ForEach(profiles) { profile in NavigationLink { AdminEditProfileScreen(profile: profile, canManage: canManage) } label: { HStack(spacing: 12) { Text(ProfileAvatars.emoji(for: profile.avatarId)).font(.title2) VStack(alignment: .leading, spacing: 2) { Text(profile.name).foregroundStyle(McColors.text) Text(profile.isOwner == true ? "Hoofdprofiel" : (profile.isKids == true ? "Kids" : "Profiel")) .font(McFonts.caption) .foregroundStyle(McColors.textDim) } } } .listRowBackground(McColors.bgElevated) } if canManage && profiles.count < 8 { Button { showCreate = true } label: { Label("Profiel toevoegen", systemImage: "plus.circle.fill") .foregroundStyle(McColors.accent) } .listRowBackground(McColors.bgElevated) } if let error { Text(error).foregroundStyle(McColors.danger) .listRowBackground(McColors.bgElevated) } } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationTitle("Profielen") .navigationBarTitleDisplayMode(.inline) .task { await load() } .sheet(isPresented: $showCreate) { CreateProfileSheet { showCreate = false Task { await load() } } .environmentObject(app) } } private func load() async { do { let payload = try await app.api.listProfiles() profiles = payload.profiles canManage = payload.canManage == true app.canManageProfiles = canManage } catch { self.error = error.localizedDescription } } } struct CreateProfileSheet: 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 private let cols = [GridItem(.adaptive(minimum: 56), spacing: 10)] var body: some View { NavigationStack { Form { Section("Naam") { TextField("Naam", text: $name) } .listRowBackground(McColors.bgElevated) Section("Avatar") { LazyVGrid(columns: cols, spacing: 10) { ForEach(ProfileAvatars.all, id: \.id) { avatar in Button { avatarId = avatar.id } label: { Text(avatar.emoji) .font(.title2) .frame(width: 48, height: 48) .background(avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, in: Circle()) } .buttonStyle(.plain) } } } .listRowBackground(McColors.bgElevated) Section { Toggle("Kids-profiel", isOn: $isKids).tint(McColors.accent) SecureField("PIN (optioneel)", text: $pin).keyboardType(.numberPad) } .listRowBackground(McColors.bgElevated) if let error { Text(error).foregroundStyle(McColors.danger) .listRowBackground(McColors.bgElevated) } } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationTitle("Nieuw profiel") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Annuleer", action: onDone) } ToolbarItem(placement: .confirmationAction) { Button("Maak") { Task { await create() } } .disabled(saving || name.trimmingCharacters(in: .whitespaces).isEmpty) } } } } private func create() async { saving = true error = nil do { _ = try await app.api.createProfile( name: name.trimmingCharacters(in: .whitespacesAndNewlines), avatarId: avatarId, isKids: isKids, pin: pin.isEmpty ? nil : pin ) saving = false onDone() } catch { self.error = error.localizedDescription saving = false } } } struct AdminEditProfileScreen: View { @EnvironmentObject private var app: AppModel let profile: ViewerProfile let canManage: Bool @Environment(\.dismiss) private var dismiss @State private var name = "" @State private var avatarId = "fox" @State private var showAvatar = true @State private var hasCustomAvatar = false @State private var remoteAvatarUrl: String? @State private var isKids = false @State private var pin = "" @State private var clearPin = false @State private var clearCustomAvatar = false @State private var pendingImage: UIImage? @State private var pickerItem: PhotosPickerItem? @State private var error: String? @State private var message: String? var body: some View { Form { Section("Profielfoto") { HStack { Spacer() Group { if let pendingImage { Image(uiImage: pendingImage).resizable().scaledToFill() } else if hasCustomAvatar, let remoteAvatarUrl { AuthenticatedAvatarImage(path: remoteAvatarUrl) } else { Text(ProfileAvatars.emoji(for: avatarId)) .font(.system(size: 40)) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.surface) } } .frame(width: 88, height: 88) .clipShape(Circle()) Spacer() } Toggle("Profielfoto aan/uit", isOn: $showAvatar) .tint(McColors.accent) PhotosPicker(selection: $pickerItem, matching: .images) { Label("Foto kiezen", systemImage: "photo") } if hasCustomAvatar || pendingImage != nil { Button("Foto verwijderen", role: .destructive) { pendingImage = nil pickerItem = nil clearCustomAvatar = true hasCustomAvatar = false } } } .listRowBackground(McColors.bgElevated) .onChange(of: pickerItem) { _, item in Task { guard let item else { return } if let data = try? await item.loadTransferable(type: Data.self), let image = UIImage(data: data) { pendingImage = image.squared() clearCustomAvatar = false hasCustomAvatar = true } } } Section("Naam") { TextField("Naam", text: $name) } .listRowBackground(McColors.bgElevated) Section("Avatar") { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(ProfileAvatars.all, id: \.id) { avatar in Button { avatarId = avatar.id } label: { Text(avatar.emoji) .font(.title) .padding(8) .background(avatarId == avatar.id ? McColors.accent.opacity(0.25) : McColors.surface, in: Circle()) } .buttonStyle(.plain) } } } } .listRowBackground(McColors.bgElevated) if canManage && profile.isOwner != true { Section { Toggle("Kids-profiel", isOn: $isKids).tint(McColors.accent) } .listRowBackground(McColors.bgElevated) } Section("PIN") { SecureField("Nieuwe PIN", text: $pin).keyboardType(.numberPad) Toggle("PIN verwijderen", isOn: $clearPin).tint(McColors.accent) } .listRowBackground(McColors.bgElevated) Section { Button("Opslaan") { Task { await save() } } .listRowBackground(McColors.bgElevated) } if canManage && profile.isOwner != true { Section { Button("Verwijderen", role: .destructive) { Task { await remove() } } .listRowBackground(McColors.bgElevated) } } if let message { Text(message).foregroundStyle(McColors.success).listRowBackground(McColors.bgElevated) } if let error { Text(error).foregroundStyle(McColors.danger).listRowBackground(McColors.bgElevated) } } .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationTitle(profile.name) .task { name = profile.name avatarId = profile.avatarId ?? "fox" isKids = profile.isKids == true showAvatar = profile.showsAvatar hasCustomAvatar = profile.hasCustomPhoto remoteAvatarUrl = profile.avatarUrl } } private func save() async { do { var updated = try await app.api.updateProfile( profileId: profile.id, name: name.trimmingCharacters(in: .whitespacesAndNewlines), avatarId: avatarId, showAvatar: showAvatar, clearAvatarImage: clearCustomAvatar && pendingImage == nil, isKids: canManage && profile.isOwner != true ? isKids : nil, pin: clearPin ? nil : (pin.isEmpty ? nil : pin), clearPin: clearPin ) if let pendingImage, let jpeg = pendingImage.jpegData(compressionQuality: 0.85) { updated = try await app.api.uploadProfileAvatar( profileId: updated.id, imageBase64: jpeg.base64EncodedString() ) } if updated.id == app.store.profileId { app.store.setProfile( id: updated.id, name: updated.name, avatarId: updated.avatarId, showAvatar: updated.showsAvatar, avatarUrl: updated.avatarUrl ?? "" ) } hasCustomAvatar = updated.hasCustomPhoto remoteAvatarUrl = updated.avatarUrl pendingImage = nil clearCustomAvatar = false message = "Opgeslagen" error = nil } catch { self.error = error.localizedDescription } } private func remove() async { do { try await app.api.deleteProfile(profileId: profile.id) dismiss() } catch { self.error = error.localizedDescription } } } struct AuthenticatedAvatarImage: View { @EnvironmentObject private var app: AppModel let path: String @State private var image: UIImage? var body: some View { Group { if let image { Image(uiImage: image) .resizable() .scaledToFill() } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(McColors.surface) } } .task(id: path) { await load() } } private func load() async { guard let url = await app.api.absoluteUrl(path) else { return } var req = URLRequest(url: url) if let token = await app.api.bearerToken() { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let (data, _) = try? await URLSession.shared.data(for: req), let ui = UIImage(data: data) { image = ui } } } private extension UIImage { func squared() -> UIImage { let side = min(size.width, size.height) let origin = CGPoint(x: (size.width - side) / 2, y: (size.height - side) / 2) let crop = CGRect(origin: origin, size: CGSize(width: side, height: side)) guard let cg = cgImage?.cropping(to: CGRect( x: crop.origin.x * scale, y: crop.origin.y * scale, width: crop.size.width * scale, height: crop.size.height * scale )) else { return self } let squared = UIImage(cgImage: cg, scale: scale, orientation: imageOrientation) let target: CGFloat = 512 let format = UIGraphicsImageRendererFormat.default() format.scale = 1 let renderer = UIGraphicsImageRenderer(size: CGSize(width: target, height: target), format: format) return renderer.image { _ in squared.draw(in: CGRect(x: 0, y: 0, width: target, height: target)) } } } struct ShelfPrefsScreen: View { @EnvironmentObject private var app: AppModel @State private var prefs: [ShelfPref] = [] @State private var error: String? @State private var message: String? var body: some View { List { Section { Text("Zet planken aan/uit en sleep om te sorteren. Dit bepaalt Home én Films/Series.") .font(McFonts.caption) .foregroundStyle(McColors.textDim) } .listRowBackground(McColors.bgElevated) Section("Planken") { ForEach($prefs) { $pref in Toggle(isOn: $pref.visible) { VStack(alignment: .leading, spacing: 2) { Text(pref.name).foregroundStyle(McColors.text) Text((pref.kind ?? "").uppercased()) .font(McFonts.caption) .foregroundStyle(McColors.textDim) } } .tint(McColors.accent) .listRowBackground(McColors.bgElevated) .onChange(of: pref.visible) { _, _ in Task { await persist() } } } .onMove { from, to in prefs.move(fromOffsets: from, toOffset: to) Task { await persist() } } } if let message { Text(message).foregroundStyle(McColors.success).listRowBackground(McColors.bgElevated) } if let error { Text(error).foregroundStyle(McColors.danger).listRowBackground(McColors.bgElevated) } } .environment(\.editMode, .constant(.active)) .scrollContentBackground(.hidden) .background(McColors.bg.ignoresSafeArea()) .navigationTitle("Planken") .navigationBarTitleDisplayMode(.inline) .task { await load() } } private func load() async { do { prefs = try await app.api.getShelfPrefs().sorted { $0.sortOrder < $1.sortOrder } } catch { self.error = error.localizedDescription } } private func persist() async { do { let next = prefs.enumerated().map { i, p in ShelfPref( shelfId: p.shelfId, name: p.name, slug: p.slug, kind: p.kind, visible: p.visible, sortOrder: i ) } prefs = next try await app.api.saveShelfPrefs(next) message = "Opgeslagen" error = nil } catch { self.error = error.localizedDescription } } }