Files
bookstax/bookstax/Views/Settings/SettingsView.swift
T
2026-04-12 12:19:53 +02:00

359 lines
15 KiB
Swift

import SwiftUI
import SafariServices
struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
@AppStorage("loggingEnabled") private var loggingEnabled = false
@Environment(ServerProfileStore.self) private var profileStore
private var selectedTheme: AccentTheme {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
@State private var showSignOutAlert = false
@State private var showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
@State private var showLogViewer = false
@State private var shareItems: [Any]? = nil
@State private var showAddServer = false
@State private var profileToSwitch: ServerProfile? = nil
@State private var profileToDelete: ServerProfile? = nil
@State private var profileToEdit: ServerProfile? = nil
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
var body: some View {
NavigationStack {
Form {
// Language section
Section {
ForEach(LanguageManager.Language.allCases) { lang in
Button {
selectedLanguage = lang
LanguageManager.shared.set(lang)
} label: {
HStack {
Text(lang.flag)
Text(lang.displayName)
.foregroundStyle(.primary)
Spacer()
if selectedLanguage == lang {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
}
} header: {
Text(L("settings.language.header"))
}
// Appearance section
Section(L("settings.appearance")) {
Picker(L("settings.appearance.theme"), selection: $appTheme) {
Text(L("settings.appearance.theme.system")).tag("system")
Text(L("settings.appearance.theme.light")).tag("light")
Text(L("settings.appearance.theme.dark")).tag("dark")
}
.pickerStyle(.segmented)
VStack(alignment: .leading, spacing: 10) {
Text(L("settings.appearance.accent"))
.font(.subheadline)
.foregroundStyle(.secondary)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 8), spacing: 10) {
ForEach(AccentTheme.allCases) { theme in
Button {
accentThemeRaw = theme.rawValue
} label: {
ZStack {
Circle()
.fill(theme.shelfColor)
.frame(width: 32, height: 32)
if selectedTheme == theme {
Circle()
.strokeBorder(.white, lineWidth: 2)
.frame(width: 32, height: 32)
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(theme.displayName)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
}
}
.padding(.vertical, 4)
}
// Servers section
Section(L("settings.servers")) {
ForEach(profileStore.profiles) { profile in
Button {
if profile.id != profileStore.activeProfileId {
profileToSwitch = profile
}
} label: {
HStack(spacing: 12) {
Image(systemName: profile.id == profileStore.activeProfileId
? "checkmark.circle.fill" : "circle")
.foregroundStyle(profile.id == profileStore.activeProfileId
? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
Text(profile.serverURL)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if profile.id == profileStore.activeProfileId {
Text(L("settings.servers.active"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
profileToDelete = profile
} label: {
Label(L("settings.servers.delete.confirm"), systemImage: "trash")
}
.tint(.red)
Button {
profileToEdit = profile
} label: {
Label(L("settings.servers.edit"), systemImage: "pencil")
}
.tint(.blue)
}
}
Button {
showAddServer = true
} label: {
Label(L("settings.servers.add"), systemImage: "plus.circle")
}
}
// Reader section
Section(L("settings.reader")) {
Toggle(L("settings.reader.showcomments"), isOn: $showComments)
}
// Logging section
Section(L("settings.log")) {
Toggle(L("settings.log.enabled"), isOn: $loggingEnabled)
.onChange(of: loggingEnabled) { _, newValue in
LogManager.shared.isEnabled = newValue
}
if loggingEnabled {
Button {
showLogViewer = true
} label: {
Label(L("settings.log.viewer.title"), systemImage: "list.bullet.rectangle")
}
Button {
let text = LogManager.shared.exportText()
shareItems = [text]
} label: {
Label(L("settings.log.share"), systemImage: "square.and.arrow.up")
}
Button(role: .destructive) {
LogManager.shared.clear()
} label: {
Label(L("settings.log.clear"), systemImage: "trash")
}
}
}
// Donate section
DonationSectionView()
// About section
Section(L("settings.about")) {
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
Button {
showSafari = URL(string: "https://www.bookstackapp.com/docs")
} label: {
Label(L("settings.about.docs"), systemImage: "book.pages")
}
Button {
showSafari = URL(string: "https://github.com/BookStackApp/BookStack/issues")
} label: {
Label(L("settings.about.issue"), systemImage: "exclamationmark.bubble")
}
Text(L("settings.about.credit"))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(L("settings.title"))
.onAppear {
loggingEnabled = LogManager.shared.isEnabled
}
// Switch server confirmation
.alert(L("settings.servers.switch.title"), isPresented: Binding(
get: { profileToSwitch != nil },
set: { if !$0 { profileToSwitch = nil } }
)) {
Button(L("settings.servers.switch.confirm")) {
if let p = profileToSwitch { profileStore.activate(p) }
profileToSwitch = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToSwitch = nil }
} message: {
if let p = profileToSwitch {
Text(String(format: L("settings.servers.switch.message"), p.name))
}
}
// Delete inactive server confirmation
.alert(L("settings.servers.delete.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id != profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.message"), p.name))
}
}
// Delete ACTIVE server stronger warning
.alert(L("settings.servers.delete.active.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id == profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.active.message"), p.name))
}
}
.sheet(isPresented: $showAddServer) { AddServerView() }
.sheet(item: $profileToEdit) { profile in EditServerView(profile: profile) }
.sheet(item: $showSafari) { url in
SafariView(url: url).ignoresSafeArea()
}
.sheet(isPresented: $showLogViewer) { LogViewerView() }
.sheet(isPresented: Binding(
get: { shareItems != nil },
set: { if !$0 { shareItems = nil } }
)) {
if let items = shareItems { ShareSheet(items: items) }
}
}
}
// MARK: - Actions
private func removeProfile(_ profile: ServerProfile) {
profileStore.remove(profile)
if profileStore.profiles.isEmpty {
onboardingComplete = false
}
}
}
// MARK: - Safari View
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
}
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
// MARK: - Log Viewer
struct LogViewerView: View {
@Environment(\.dismiss) private var dismiss
@State private var logManager = LogManager.shared
var body: some View {
NavigationStack {
Group {
if logManager.entries.isEmpty {
ContentUnavailableView(
L("settings.log.viewer.title"),
systemImage: "list.bullet.rectangle",
description: Text("No log entries yet.")
)
} else {
List(logManager.entries.reversed()) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(entry.formatted)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(colorFor(entry.level))
}
.listRowInsets(.init(top: 4, leading: 12, bottom: 4, trailing: 12))
}
.listStyle(.plain)
}
}
.navigationTitle(L("settings.log.viewer.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L("common.ok")) { dismiss() }
}
}
}
}
private func colorFor(_ level: LogEntry.Level) -> Color {
switch level {
case .debug: return .secondary
case .info: return .primary
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
#Preview {
SettingsView()
}