feat(settings): Preferences section skeleton + Language setting
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.
This commit is contained in:
@@ -32,6 +32,8 @@ struct SettingsView: View {
|
|||||||
@State private var editableName = ""
|
@State private var editableName = ""
|
||||||
@State private var isSavingName = false
|
@State private var isSavingName = false
|
||||||
@State private var nameErrorMessage: String?
|
@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
|
/// Full Local Sync and cache-clearing both need a real connection to be
|
||||||
/// safe — clearing while offline (or letting Full Local Sync think it
|
/// safe — clearing while offline (or letting Full Local Sync think it
|
||||||
@@ -71,6 +73,7 @@ struct SettingsView: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .profile: profileDetail
|
case .profile: profileDetail
|
||||||
|
case .preferences: preferencesDetail
|
||||||
case .offlineSync: offlineSyncDetail
|
case .offlineSync: offlineSyncDetail
|
||||||
case .advanced: advancedDetail
|
case .advanced: advancedDetail
|
||||||
case .about: aboutDetail
|
case .about: aboutDetail
|
||||||
@@ -231,6 +234,84 @@ struct SettingsView: View {
|
|||||||
.frame(maxWidth: 420, alignment: .leading)
|
.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<Content: View>(
|
||||||
|
_ 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<String> {
|
||||||
|
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
|
// MARK: - Offline & Sync
|
||||||
|
|
||||||
private var offlineSyncDetail: some View {
|
private var offlineSyncDetail: some View {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
/// placeholder until its content is specified and built.
|
/// placeholder until its content is specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .offlineSync, .advanced, .about, .profile:
|
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user