Multi-Server implementiert
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// MARK: - ServerProfile
|
||||
|
||||
struct ServerProfile: Codable, Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var serverURL: String
|
||||
}
|
||||
|
||||
// MARK: - ServerProfileStore
|
||||
|
||||
@Observable
|
||||
final class ServerProfileStore {
|
||||
static let shared = ServerProfileStore()
|
||||
|
||||
private(set) var profiles: [ServerProfile] = []
|
||||
private(set) var activeProfileId: UUID?
|
||||
|
||||
var activeProfile: ServerProfile? {
|
||||
profiles.first { $0.id == activeProfileId }
|
||||
}
|
||||
|
||||
private let profilesKey = "serverProfiles"
|
||||
private let activeIdKey = "activeProfileId"
|
||||
|
||||
private init() {
|
||||
load()
|
||||
migrate()
|
||||
}
|
||||
|
||||
// MARK: - Add
|
||||
|
||||
/// Adds a new profile and persists its credentials synchronously.
|
||||
func addProfile(_ profile: ServerProfile, tokenId: String, tokenSecret: String) {
|
||||
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
|
||||
profiles.append(profile)
|
||||
save()
|
||||
activate(profile)
|
||||
}
|
||||
|
||||
// MARK: - Activate
|
||||
|
||||
func activate(_ profile: ServerProfile) {
|
||||
guard let creds = KeychainService.loadCredentialsSync(profileId: profile.id) else { return }
|
||||
activeProfileId = profile.id
|
||||
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
|
||||
// Keep legacy "serverURL" key in sync for BookStackAPI
|
||||
UserDefaults.standard.set(profile.serverURL, forKey: "serverURL")
|
||||
Task {
|
||||
await BookStackAPI.shared.configure(
|
||||
serverURL: profile.serverURL,
|
||||
tokenId: creds.tokenId,
|
||||
tokenSecret: creds.tokenSecret
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remove
|
||||
|
||||
func remove(_ profile: ServerProfile) {
|
||||
profiles.removeAll { $0.id == profile.id }
|
||||
KeychainService.deleteCredentialsSync(profileId: profile.id)
|
||||
save()
|
||||
if activeProfileId == profile.id {
|
||||
activeProfileId = nil
|
||||
UserDefaults.standard.removeObject(forKey: activeIdKey)
|
||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
/// Updates the name/URL of an existing profile. Optionally rotates credentials.
|
||||
func updateProfile(_ profile: ServerProfile, newName: String, newURL: String,
|
||||
newTokenId: String? = nil, newTokenSecret: String? = nil) {
|
||||
guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
|
||||
profiles[idx].name = newName
|
||||
profiles[idx].serverURL = newURL
|
||||
if let id = newTokenId, let secret = newTokenSecret {
|
||||
KeychainService.saveCredentialsSync(tokenId: id, tokenSecret: secret, profileId: profile.id)
|
||||
}
|
||||
save()
|
||||
// If this is the active profile, re-configure the API client
|
||||
if activeProfileId == profile.id {
|
||||
UserDefaults.standard.set(newURL, forKey: "serverURL")
|
||||
let creds = (newTokenId != nil && newTokenSecret != nil)
|
||||
? (tokenId: newTokenId!, tokenSecret: newTokenSecret!)
|
||||
: KeychainService.loadCredentialsSync(profileId: profile.id) ?? (tokenId: "", tokenSecret: "")
|
||||
Task {
|
||||
await BookStackAPI.shared.configure(
|
||||
serverURL: newURL,
|
||||
tokenId: creds.tokenId,
|
||||
tokenSecret: creds.tokenSecret
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(profiles) else { return }
|
||||
UserDefaults.standard.set(data, forKey: profilesKey)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
if let data = UserDefaults.standard.data(forKey: profilesKey),
|
||||
let decoded = try? JSONDecoder().decode([ServerProfile].self, from: data) {
|
||||
profiles = decoded
|
||||
}
|
||||
if let idString = UserDefaults.standard.string(forKey: activeIdKey),
|
||||
let uuid = UUID(uuidString: idString) {
|
||||
activeProfileId = uuid
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration from legacy single-server config
|
||||
|
||||
private func migrate() {
|
||||
guard profiles.isEmpty,
|
||||
let url = UserDefaults.standard.string(forKey: "serverURL"),
|
||||
!url.isEmpty,
|
||||
let tokenId = KeychainService.loadSync(key: "tokenId"),
|
||||
let tokenSecret = KeychainService.loadSync(key: "tokenSecret") else { return }
|
||||
|
||||
let profile = ServerProfile(id: UUID(), name: "BookStack", serverURL: url)
|
||||
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
|
||||
profiles.append(profile)
|
||||
save()
|
||||
activeProfileId = profile.id
|
||||
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
|
||||
// Leave legacy "serverURL" in place so BookStackAPI continues working after migration.
|
||||
AppLog(.info, "Migrated legacy server config to profile \(profile.id)", category: "ServerProfile")
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,68 @@ actor KeychainService {
|
||||
try delete(key: tokenSecretKey)
|
||||
}
|
||||
|
||||
// MARK: - Synchronous static helper (for use at app init before async context)
|
||||
// MARK: - Per-profile credential methods
|
||||
|
||||
func saveCredentials(tokenId: String, tokenSecret: String, profileId: UUID) throws {
|
||||
try save(value: tokenId, key: "tokenId-\(profileId.uuidString)")
|
||||
try save(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
func loadCredentials(profileId: UUID) throws -> (tokenId: String, tokenSecret: String)? {
|
||||
guard let id = try load(key: "tokenId-\(profileId.uuidString)"),
|
||||
let secret = try load(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
|
||||
return (id, secret)
|
||||
}
|
||||
|
||||
func deleteCredentials(profileId: UUID) throws {
|
||||
try delete(key: "tokenId-\(profileId.uuidString)")
|
||||
try delete(key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
// MARK: - Synchronous static helpers (for use at app init / non-async contexts)
|
||||
|
||||
static func loadCredentialsSync(profileId: UUID) -> (tokenId: String, tokenSecret: String)? {
|
||||
guard let tokenId = loadSync(key: "tokenId-\(profileId.uuidString)"),
|
||||
let tokenSecret = loadSync(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
|
||||
return (tokenId, tokenSecret)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func saveSync(value: String, key: String) -> Bool {
|
||||
let service = "com.bookstax.credentials"
|
||||
guard let data = value.data(using: .utf8) else { return false }
|
||||
let deleteQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
let addQuery: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key,
|
||||
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
kSecValueData: data
|
||||
]
|
||||
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func saveCredentialsSync(tokenId: String, tokenSecret: String, profileId: UUID) {
|
||||
saveSync(value: tokenId, key: "tokenId-\(profileId.uuidString)")
|
||||
saveSync(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
|
||||
}
|
||||
|
||||
static func deleteCredentialsSync(profileId: UUID) {
|
||||
let service = "com.bookstax.credentials"
|
||||
for suffix in ["tokenId-\(profileId.uuidString)", "tokenSecret-\(profileId.uuidString)"] {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: suffix
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadSync(key: String) -> String? {
|
||||
let service = "com.bookstax.credentials"
|
||||
|
||||
@@ -77,4 +77,15 @@ final class SyncService {
|
||||
async let booksTask: Void = syncBooks(context: context)
|
||||
_ = try await (shelvesTask, booksTask)
|
||||
}
|
||||
|
||||
// MARK: - Clear all cached content
|
||||
|
||||
func clearAllCache(context: ModelContext) throws {
|
||||
try context.delete(model: CachedShelf.self)
|
||||
try context.delete(model: CachedBook.self)
|
||||
try context.delete(model: CachedPage.self)
|
||||
try context.save()
|
||||
UserDefaults.standard.removeObject(forKey: "lastSynced")
|
||||
AppLog(.info, "Cleared all cached content", category: "SyncService")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ final class OnboardingViewModel {
|
||||
var navPath: NavigationPath = NavigationPath()
|
||||
|
||||
// Input
|
||||
var serverNameInput: String = ""
|
||||
var serverURLInput: String = ""
|
||||
var tokenIdInput: String = ""
|
||||
var tokenSecretInput: String = ""
|
||||
@@ -36,6 +37,10 @@ final class OnboardingViewModel {
|
||||
|
||||
// Completion
|
||||
var isComplete: Bool = false
|
||||
/// Set to true after successfully adding a server in the Settings "Add Server" flow.
|
||||
var isAddComplete: Bool = false
|
||||
/// When true, skips the ready step navigation (used in Add Server sheet).
|
||||
var isAddServerMode: Bool = false
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
@@ -117,6 +122,11 @@ final class OnboardingViewModel {
|
||||
let appName = info.appName ?? "BookStack"
|
||||
verifyPhase = .serverOK(appName: appName)
|
||||
|
||||
// Auto-populate server name from API if the user left it blank
|
||||
if serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
serverNameInput = appName
|
||||
}
|
||||
|
||||
// Configure the shared API client with validated credentials
|
||||
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
verifyPhase = .checkingToken
|
||||
@@ -124,25 +134,24 @@ final class OnboardingViewModel {
|
||||
// Attempt to fetch user info (non-fatal — some installs restrict /api/users)
|
||||
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
|
||||
|
||||
// Persist server URL and credentials
|
||||
UserDefaults.standard.set(url, forKey: "serverURL")
|
||||
do {
|
||||
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
} catch let error as BookStackError {
|
||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
||||
verifyPhase = .failed(phase: "keychain", error: error)
|
||||
return
|
||||
} catch {
|
||||
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
|
||||
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
// Create and persist a ServerProfile via the shared store
|
||||
let profile = ServerProfile(
|
||||
id: UUID(),
|
||||
name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
serverURL: url
|
||||
)
|
||||
ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
|
||||
|
||||
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
|
||||
verifyPhase = .done(appName: appName, userName: userName)
|
||||
|
||||
// Navigate to the ready step
|
||||
navPath.append(Step.ready)
|
||||
if isAddServerMode {
|
||||
// In the "Add Server" sheet: signal completion so the sheet dismisses
|
||||
isAddComplete = true
|
||||
} else {
|
||||
// Normal onboarding: navigate to the ready step
|
||||
navPath.append(Step.ready)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Complete
|
||||
|
||||
@@ -195,6 +195,18 @@ struct ConnectStepView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Server Name field
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L("onboarding.server.name.label"), systemImage: "tag")
|
||||
.font(.subheadline.bold())
|
||||
|
||||
TextField(L("onboarding.server.name.placeholder"), text: $viewModel.serverNameInput)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Server URL field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -204,6 +216,7 @@ struct ConnectStepView: View {
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.URL)
|
||||
.onChange(of: viewModel.serverURLInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
@@ -253,6 +266,7 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.username)
|
||||
.onChange(of: viewModel.tokenIdInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
@@ -293,6 +307,7 @@ struct ConnectStepView: View {
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.password)
|
||||
.onChange(of: viewModel.tokenSecretInput) {
|
||||
if case .idle = viewModel.verifyPhase { } else {
|
||||
viewModel.resetVerification()
|
||||
@@ -350,6 +365,9 @@ struct ConnectStepView: View {
|
||||
.onDisappear {
|
||||
verifyTask?.cancel()
|
||||
}
|
||||
.onChange(of: viewModel.serverURLInput) { _, _ in
|
||||
// Clear name hint when URL changes so it re-auto-fills on next verify
|
||||
}
|
||||
}
|
||||
|
||||
private var canConnect: Bool {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet that lets the user connect an additional BookStack server.
|
||||
/// Reuses ConnectStepView — skips the language/welcome steps.
|
||||
struct AddServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: OnboardingViewModel = {
|
||||
let vm = OnboardingViewModel()
|
||||
vm.isAddServerMode = true
|
||||
return vm
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ConnectStepView(viewModel: viewModel)
|
||||
.navigationTitle(L("settings.servers.add"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L("create.cancel")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.isAddComplete) { _, done in
|
||||
if done { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EditServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(ServerProfileStore.self) private var profileStore
|
||||
|
||||
let profile: ServerProfile
|
||||
|
||||
@State private var name: String
|
||||
@State private var serverURL: String
|
||||
@State private var tokenId: String = ""
|
||||
@State private var tokenSecret: String = ""
|
||||
@State private var showTokenId = false
|
||||
@State private var showTokenSecret = false
|
||||
@State private var changeCredentials = false
|
||||
@State private var isSaving = false
|
||||
@State private var saveError: String? = nil
|
||||
|
||||
init(profile: ServerProfile) {
|
||||
self.profile = profile
|
||||
_name = State(initialValue: profile.name)
|
||||
_serverURL = State(initialValue: profile.serverURL)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Name & URL
|
||||
Section(L("onboarding.server.name.label")) {
|
||||
TextField(L("onboarding.server.name.placeholder"), text: $name)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.words)
|
||||
}
|
||||
|
||||
Section(L("onboarding.server.title")) {
|
||||
TextField(L("onboarding.server.placeholder"), text: $serverURL)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.URL)
|
||||
}
|
||||
|
||||
// Optional credential rotation
|
||||
Section {
|
||||
Toggle(L("settings.servers.edit.changecreds"), isOn: $changeCredentials.animation())
|
||||
} footer: {
|
||||
Text(L("settings.servers.edit.changecreds.footer"))
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
if changeCredentials {
|
||||
Section(L("onboarding.token.id.label")) {
|
||||
HStack {
|
||||
Group {
|
||||
if showTokenId {
|
||||
TextField(L("onboarding.token.id.label"), text: $tokenId)
|
||||
} else {
|
||||
SecureField(L("onboarding.token.id.label"), text: $tokenId)
|
||||
}
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.username)
|
||||
|
||||
Button { showTokenId.toggle() } label: {
|
||||
Image(systemName: showTokenId ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Section(L("onboarding.token.secret.label")) {
|
||||
HStack {
|
||||
Group {
|
||||
if showTokenSecret {
|
||||
TextField(L("onboarding.token.secret.label"), text: $tokenSecret)
|
||||
} else {
|
||||
SecureField(L("onboarding.token.secret.label"), text: $tokenSecret)
|
||||
}
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.password)
|
||||
|
||||
Button { showTokenSecret.toggle() } label: {
|
||||
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = saveError {
|
||||
Section {
|
||||
Label(error, systemImage: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L("settings.servers.edit.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L("create.cancel")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L("common.done")) {
|
||||
save()
|
||||
}
|
||||
.disabled(!canSave || isSaving)
|
||||
.overlay { if isSaving { ProgressView().scaleEffect(0.7) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
!serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
(!changeCredentials || (!tokenId.isEmpty && !tokenSecret.isEmpty))
|
||||
}
|
||||
|
||||
private func save() {
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
|
||||
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
|
||||
cleanURL = "https://" + cleanURL
|
||||
}
|
||||
while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
|
||||
|
||||
profileStore.updateProfile(
|
||||
profile,
|
||||
newName: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
newURL: cleanURL,
|
||||
newTokenId: changeCredentials ? tokenId : nil,
|
||||
newTokenSecret: changeCredentials ? tokenSecret : nil
|
||||
)
|
||||
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@@ -8,11 +9,11 @@ struct SettingsView: View {
|
||||
@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 serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||
@State private var showSignOutAlert = false
|
||||
@State private var isSyncing = false
|
||||
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
|
||||
@@ -20,6 +21,11 @@ struct SettingsView: View {
|
||||
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
|
||||
@State private var showLogViewer = false
|
||||
@State private var shareItems: [Any]? = nil
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@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"
|
||||
@@ -93,33 +99,56 @@ struct SettingsView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// Account section
|
||||
Section(L("settings.account")) {
|
||||
HStack {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.title)
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading) {
|
||||
Text(L("settings.account.connected"))
|
||||
.font(.headline)
|
||||
Text(serverURL)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = serverURL
|
||||
showAddServer = true
|
||||
} label: {
|
||||
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showSignOutAlert = true
|
||||
} label: {
|
||||
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
|
||||
Label(L("settings.servers.add"), systemImage: "plus.circle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,11 +236,58 @@ struct SettingsView: View {
|
||||
.onAppear {
|
||||
loggingEnabled = LogManager.shared.isEnabled
|
||||
}
|
||||
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
|
||||
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
|
||||
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
|
||||
// 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: {
|
||||
Text(L("settings.signout.alert.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))
|
||||
}
|
||||
}
|
||||
// Add server sheet
|
||||
.sheet(isPresented: $showAddServer) {
|
||||
AddServerView()
|
||||
}
|
||||
// Edit server sheet
|
||||
.sheet(item: $profileToEdit) { profile in
|
||||
EditServerView(profile: profile)
|
||||
}
|
||||
.sheet(item: $showSafari) { url in
|
||||
SafariView(url: url)
|
||||
@@ -233,11 +309,13 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func signOut() {
|
||||
Task {
|
||||
try? await KeychainService.shared.deleteCredentials()
|
||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
||||
UserDefaults.standard.removeObject(forKey: "lastSynced")
|
||||
private func removeProfile(_ profile: ServerProfile) {
|
||||
profileStore.remove(profile)
|
||||
// Always clear the cache — it may contain content from this server
|
||||
try? SyncService.shared.clearAllCache(context: modelContext)
|
||||
lastSynced = nil
|
||||
// If no profiles remain, return to onboarding
|
||||
if profileStore.profiles.isEmpty {
|
||||
onboardingComplete = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ struct bookstaxApp: App {
|
||||
@AppStorage("appTheme") private var appTheme = "system"
|
||||
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
|
||||
|
||||
// ServerProfileStore is initialised here so migration runs at launch
|
||||
private var profileStore = ServerProfileStore.shared
|
||||
|
||||
private var preferredColorScheme: ColorScheme? {
|
||||
switch appTheme {
|
||||
case "light": return .light
|
||||
@@ -43,13 +46,16 @@ struct bookstaxApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if onboardingComplete {
|
||||
if onboardingComplete && profileStore.activeProfile != nil {
|
||||
MainTabView()
|
||||
.environment(ConnectivityMonitor.shared)
|
||||
// Re-creates the entire tab hierarchy when the active server changes
|
||||
.id(profileStore.activeProfileId)
|
||||
} else {
|
||||
OnboardingView()
|
||||
}
|
||||
}
|
||||
.environment(profileStore)
|
||||
.environment(\.accentTheme, accentTheme)
|
||||
.tint(accentTheme.accentColor)
|
||||
.preferredColorScheme(preferredColorScheme)
|
||||
|
||||
@@ -214,6 +214,25 @@
|
||||
"search.filter.tag" = "Tag";
|
||||
"search.filter.tag.clear" = "Tag-Filter entfernen";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Server";
|
||||
"settings.servers.add" = "Server hinzufügen…";
|
||||
"settings.servers.active" = "Aktiv";
|
||||
"settings.servers.switch.title" = "Server wechseln";
|
||||
"settings.servers.switch.message" = "Zu \"%@\" wechseln? Die App wird neu geladen.";
|
||||
"settings.servers.switch.confirm" = "Wechseln";
|
||||
"settings.servers.delete.title" = "Server entfernen";
|
||||
"settings.servers.delete.message" = "\"%@\" entfernen? Der Cache wird geleert. Dies kann nicht rückgängig gemacht werden.";
|
||||
"settings.servers.delete.confirm" = "Entfernen";
|
||||
"settings.servers.delete.active.title" = "Aktiven Server entfernen?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" ist dein aktueller Server. Durch das Entfernen werden alle zwischengespeicherten Inhalte gelöscht und du wirst von diesem Server abgemeldet.";
|
||||
"settings.servers.edit" = "Bearbeiten";
|
||||
"settings.servers.edit.title" = "Server bearbeiten";
|
||||
"settings.servers.edit.changecreds" = "API-Token aktualisieren";
|
||||
"settings.servers.edit.changecreds.footer" = "Aktivieren, um Token-ID und Secret für diesen Server zu ersetzen.";
|
||||
"onboarding.server.name.label" = "Servername";
|
||||
"onboarding.server.name.placeholder" = "z.B. Firmen-Wiki";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "OK";
|
||||
"common.error" = "Unbekannter Fehler";
|
||||
|
||||
@@ -214,6 +214,25 @@
|
||||
"search.filter.tag" = "Tag";
|
||||
"search.filter.tag.clear" = "Clear Tag Filter";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Servers";
|
||||
"settings.servers.add" = "Add Server…";
|
||||
"settings.servers.active" = "Active";
|
||||
"settings.servers.switch.title" = "Switch Server";
|
||||
"settings.servers.switch.message" = "Switch to \"%@\"? The app will reload.";
|
||||
"settings.servers.switch.confirm" = "Switch";
|
||||
"settings.servers.delete.title" = "Remove Server";
|
||||
"settings.servers.delete.message" = "Remove \"%@\"? Its cached content will be cleared. This cannot be undone.";
|
||||
"settings.servers.delete.confirm" = "Remove";
|
||||
"settings.servers.delete.active.title" = "Remove Active Server?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" is your current server. Removing it will clear all cached content and sign you out of this server.";
|
||||
"settings.servers.edit" = "Edit";
|
||||
"settings.servers.edit.title" = "Edit Server";
|
||||
"settings.servers.edit.changecreds" = "Update API Token";
|
||||
"settings.servers.edit.changecreds.footer" = "Enable to replace the stored Token ID and Secret for this server.";
|
||||
"onboarding.server.name.label" = "Server Name";
|
||||
"onboarding.server.name.placeholder" = "e.g. Work Wiki";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "OK";
|
||||
"common.error" = "Unknown error";
|
||||
|
||||
@@ -214,6 +214,25 @@
|
||||
"search.filter.tag" = "Etiqueta";
|
||||
"search.filter.tag.clear" = "Eliminar filtro de etiqueta";
|
||||
|
||||
// MARK: - Servers
|
||||
"settings.servers" = "Servidores";
|
||||
"settings.servers.add" = "Añadir servidor…";
|
||||
"settings.servers.active" = "Activo";
|
||||
"settings.servers.switch.title" = "Cambiar servidor";
|
||||
"settings.servers.switch.message" = "¿Cambiar a \"%@\"? La app se recargará.";
|
||||
"settings.servers.switch.confirm" = "Cambiar";
|
||||
"settings.servers.delete.title" = "Eliminar servidor";
|
||||
"settings.servers.delete.message" = "¿Eliminar \"%@\"? Se borrará el contenido en caché. Esta acción no se puede deshacer.";
|
||||
"settings.servers.delete.confirm" = "Eliminar";
|
||||
"settings.servers.delete.active.title" = "¿Eliminar el servidor activo?";
|
||||
"settings.servers.delete.active.message" = "\"%@\" es tu servidor actual. Al eliminarlo se borrará todo el contenido en caché y se cerrará sesión en este servidor.";
|
||||
"settings.servers.edit" = "Editar";
|
||||
"settings.servers.edit.title" = "Editar servidor";
|
||||
"settings.servers.edit.changecreds" = "Actualizar token API";
|
||||
"settings.servers.edit.changecreds.footer" = "Activa para reemplazar el Token ID y Secret almacenados para este servidor.";
|
||||
"onboarding.server.name.label" = "Nombre del servidor";
|
||||
"onboarding.server.name.placeholder" = "p.ej. Wiki de trabajo";
|
||||
|
||||
// MARK: - Common
|
||||
"common.ok" = "Aceptar";
|
||||
"common.error" = "Error desconocido";
|
||||
|
||||
Reference in New Issue
Block a user