From ba3ede9ae23d3f41db7969859533961922002e4e Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 20:29:54 +0100 Subject: [PATCH] feat(settings): Preferences section skeleton + Language setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks .preferences implemented, adds the section header/description and a Display subsection. Language is real and wired end-to-end (users.update language field, high confidence per Outline's public API docs) — the rest of the toggles land in follow-up commits, one setting at a time. --- Outpost/Features/Account/SettingsView.swift | 81 +++++++++++++++++++++ Outpost/Root/AppNavigation.swift | 2 +- Outpost/Support/OutlineLocale.swift | 34 +++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 Outpost/Support/OutlineLocale.swift diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 13de716..8677af2 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -32,6 +32,8 @@ struct SettingsView: View { @State private var editableName = "" @State private var isSavingName = false @State private var nameErrorMessage: String? + @State private var isSavingLanguage = false + @State private var languageErrorMessage: String? /// Full Local Sync and cache-clearing both need a real connection to be /// safe — clearing while offline (or letting Full Local Sync think it @@ -71,6 +73,7 @@ struct SettingsView: View { switch section { case .appearance: appearanceDetail case .profile: profileDetail + case .preferences: preferencesDetail case .offlineSync: offlineSyncDetail case .advanced: advancedDetail case .about: aboutDetail @@ -231,6 +234,84 @@ struct SettingsView: View { .frame(maxWidth: 420, alignment: .leading) } + // MARK: - Preferences + + private var preferencesDetail: some View { + VStack(alignment: .leading, spacing: 24) { + sectionHeader + Text("Manage settings that affect your personal experience.") + .font(.subheadline) + .foregroundStyle(.secondary) + + preferencesSubsection("Display") { + languageRow + } + } + .frame(maxWidth: 480, alignment: .leading) + .task { await refreshProfile() } + } + + private func preferencesSubsection( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 14) { + Text(title) + .font(.headline) + content() + } + } + + private var languageRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Language") + Text("Choose the interface language. Community translations are accepted through our translation portal.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isSavingLanguage { + ProgressView().controlSize(.small) + } else { + Picker("Language", selection: languageBinding) { + ForEach(OutlineLocale.all) { locale in + Text(locale.label).tag(locale.code) + } + } + .labelsHidden() + .frame(width: 200) + } + } + if let languageErrorMessage { + Text(languageErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + private var languageBinding: Binding { + Binding( + get: { session.userLanguage ?? "en_US" }, + set: { newValue in Task { await saveLanguage(newValue) } } + ) + } + + private func saveLanguage(_ code: String) async { + guard code != session.userLanguage, let apiClient = session.apiClient, let userId = session.userId else { return } + isSavingLanguage = true + defer { isSavingLanguage = false } + do { + let updated = try await apiClient.updateUserLanguage(UpdateUserLanguageRequest(id: userId, language: code)) + session.applyUpdatedProfile(updated) + languageErrorMessage = nil + } catch { + languageErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update your language.") + } + } + // MARK: - Offline & Sync private var offlineSyncDetail: some View { diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index ad64665..47f47bc 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -122,7 +122,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { /// placeholder until its content is specified and built. var isImplemented: Bool { switch self { - case .appearance, .offlineSync, .advanced, .about, .profile: + case .appearance, .offlineSync, .advanced, .about, .profile, .preferences: return true default: return false diff --git a/Outpost/Support/OutlineLocale.swift b/Outpost/Support/OutlineLocale.swift new file mode 100644 index 0000000..4099726 --- /dev/null +++ b/Outpost/Support/OutlineLocale.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Outline's interface-language options. Not exhaustive — Outline accepts +/// community translations via its own translation portal, so the real list +/// on any given server can be longer than this; this covers the common +/// cases and falls back to showing whatever code the server already has +/// set even if it isn't in this list. +struct OutlineLocale: Identifiable, Hashable { + let code: String + let label: String + + var id: String { code } + + static let all: [OutlineLocale] = [ + OutlineLocale(code: "en_US", label: "English (US)"), + OutlineLocale(code: "de_DE", label: "Deutsch"), + OutlineLocale(code: "fr_FR", label: "Français"), + OutlineLocale(code: "es_ES", label: "Español"), + OutlineLocale(code: "pt_PT", label: "Português"), + OutlineLocale(code: "pt_BR", label: "Português (Brasil)"), + OutlineLocale(code: "it_IT", label: "Italiano"), + OutlineLocale(code: "nl_NL", label: "Nederlands"), + OutlineLocale(code: "pl_PL", label: "Polski"), + OutlineLocale(code: "ru_RU", label: "Русский"), + OutlineLocale(code: "ja_JP", label: "日本語"), + OutlineLocale(code: "ko_KR", label: "한국어"), + OutlineLocale(code: "zh_CN", label: "中文 (简体)"), + OutlineLocale(code: "zh_TW", label: "中文 (繁體)"), + ] + + static func label(for code: String) -> String { + all.first(where: { $0.code == code })?.label ?? code + } +}