redesign(settings): section list + single-section detail

Replaces the stacked/gridded cards with the same shape as macOS System
Settings: a section list on the left, one section's content in the
detail pane on the right. Sections never render next to each other
anymore, so there's no card-height mismatch to look weird, and the
layout holds up at any window size or aspect ratio without needing a
grid to reflow.
This commit is contained in:
2026-08-15 00:29:51 +01:00
parent e991f4ed4c
commit 19eb1bae69
+122 -76
View File
@@ -2,11 +2,41 @@
import SwiftUI
import OutlineKit
private enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case appearance, account, offlineSync, about
var id: String { rawValue }
var title: String {
switch self {
case .appearance: return "Appearance"
case .account: return "Account"
case .offlineSync: return "Offline & Sync"
case .about: return "About"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .account: return "person.crop.circle"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .about: return "info.circle"
}
}
}
/// Full-page Settings swapped into `ContentView_macOS`'s detail pane (see
/// `AppNavigation`), alongside the sidebar, not a separate popup window.
/// Replaces the old `Settings {}` scene / `PreferencesView` and folds in
/// what used to be the standalone "About Outpost" window's content too, so
/// everything about the app lives in one place.
///
/// Own section list + single-section detail (same shape as macOS System
/// Settings) rather than a page of stacked/gridded cards cards of visibly
/// different heights never sit next to each other for comparison this way,
/// and the layout holds up at any window size or aspect ratio without
/// needing to reflow a grid.
struct SettingsView: View {
let onDone: () -> Void
@@ -14,6 +44,7 @@ struct SettingsView: View {
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@State private var selectedSection: SettingsSection? = .appearance
@State private var isShowingLogoutConfirmation = false
@State private var storageSummary: CacheStorageSummary?
@State private var pendingOperations: [PendingOperationSummary] = []
@@ -26,25 +57,17 @@ struct SettingsView: View {
VStack(spacing: 0) {
header
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Appearance and Account are short let them sit
// side-by-side when there's room instead of each
// claiming a full-width row on their own. Offline & Sync
// and About stay full-width, own row each: both are
// taller and visibly uneven height content, paired with
// an adaptive grid, looks worse than just stacking.
LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 20)], alignment: .leading, spacing: 20) {
appearanceSection
accountSection
}
offlineSyncSection
aboutSection
HStack(spacing: 0) {
sectionList
.frame(width: 190)
Divider()
ScrollView {
sectionDetail(selectedSection ?? .appearance)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(24)
.frame(maxWidth: 900)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
@@ -63,10 +86,34 @@ struct SettingsView: View {
.padding(20)
}
private var sectionList: some View {
List(SettingsSection.allCases, selection: $selectedSection) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
.listStyle(.sidebar)
}
@ViewBuilder
private func sectionDetail(_ section: SettingsSection) -> some View {
switch section {
case .appearance: appearanceDetail
case .account: accountDetail
case .offlineSync: offlineSyncDetail
case .about: aboutDetail
}
}
private func sectionHeader(_ section: SettingsSection) -> some View {
Text(section.title)
.font(.title.bold())
}
// MARK: - Appearance
private var appearanceSection: some View {
section("Appearance", icon: "paintbrush") {
private var appearanceDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader(.appearance)
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
@@ -74,13 +121,15 @@ struct SettingsView: View {
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
}
}
// MARK: - Account
private var accountSection: some View {
section("Account", icon: "person.crop.circle") {
private var accountDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader(.account)
VStack(alignment: .leading, spacing: 10) {
labeledRow("Signed in as", session.userName ?? "")
if let email = session.userEmail {
@@ -89,64 +138,70 @@ struct SettingsView: View {
if let teamName = session.teamName {
labeledRow("Workspace", teamName)
}
}
.frame(maxWidth: 420)
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
.padding(.top, 4)
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
// MARK: - Offline & Sync
private var offlineSyncSection: some View {
section("Offline & Sync", icon: "arrow.triangle.2.circlepath") {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption)
.foregroundStyle(.secondary)
}
private var offlineSyncDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader(.offlineSync)
Divider()
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
.onChange(of: isFullLocalSyncEnabled) { _, enabled in
if enabled { Task { await runFullSync() } }
}
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
.onChange(of: isFullLocalSyncEnabled) { _, enabled in
if enabled { Task { await runFullSync() } }
}
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
if isFullLocalSyncEnabled {
HStack(spacing: 8) {
if isSyncing {
ProgressView().controlSize(.small)
Text("Syncing…")
if isFullLocalSyncEnabled {
HStack(spacing: 8) {
if isSyncing {
ProgressView().controlSize(.small)
Text("Syncing…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Sync Now") { Task { await runFullSync() } }
.controlSize(.small)
if let lastFullSyncSummary {
Text(fullSyncSummaryText(lastFullSyncSummary))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Sync Now") { Task { await runFullSync() } }
.controlSize(.small)
if let lastFullSyncSummary {
Text(fullSyncSummaryText(lastFullSyncSummary))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Divider()
storageRow
Divider()
pendingOperationsRow
}
Divider()
.frame(maxWidth: 480)
storageRow
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
pendingOperationsRow
.frame(maxWidth: 480, alignment: .leading)
}
}
@@ -246,25 +301,16 @@ struct SettingsView: View {
// MARK: - About
private var aboutSection: some View {
section("About", icon: "info.circle") {
private var aboutDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader(.about)
AboutInfoView()
.frame(maxWidth: 420, alignment: .leading)
}
}
// MARK: - Helpers
private func section<Content: View>(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: icon)
.font(.headline)
content()
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 10))
}
private func labeledRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)