stremio/apps/ios/VMC/UI/SettingsScreen.swift
Jos Vooges | STH 6ffeb50733 Add in-app device approve/QR pairing and polish iOS browse.
Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 03:18:38 +02:00

554 lines
20 KiB
Swift

import SwiftUI
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)
Button {
Task { await app.logout() }
} label: {
Label("Server / account wisselen", systemImage: "server.rack")
.foregroundStyle(McColors.text)
}
.listRowBackground(McColors.bgElevated)
}
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.4.6")
LabeledContent("Live TV", value: app.iptvEnabled ? "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 pin = ""
@State private var clearPin = false
@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("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 {
name = app.store.profileName ?? ""
avatarId = app.store.profileAvatarId ?? "fox"
}
}
private func save() async {
guard let profileId else { return }
saving = true
error = nil
message = nil
do {
let updated = try await app.api.updateProfile(
profileId: profileId,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin
)
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
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 isKids = false
@State private var pin = ""
@State private var clearPin = false
@State private var error: String?
@State private var message: String?
var body: some View {
Form {
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
}
}
private func save() async {
do {
let updated = try await app.api.updateProfile(
profileId: profile.id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
avatarId: avatarId,
isKids: canManage && profile.isOwner != true ? isKids : nil,
pin: clearPin ? nil : (pin.isEmpty ? nil : pin),
clearPin: clearPin
)
if updated.id == app.store.profileId {
app.store.setProfile(id: updated.id, name: updated.name, avatarId: updated.avatarId)
}
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 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
}
}
}