359 lines
15 KiB
Swift
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()
|
|
}
|