stremio/apps/ios/VMC/UI/SettingsScreen.swift

785 lines
29 KiB
Swift

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.43")
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
}
}
}