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
+87 -41
View File
@@ -2,11 +2,41 @@
import SwiftUI import SwiftUI
import OutlineKit 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 /// Full-page Settings swapped into `ContentView_macOS`'s detail pane (see
/// `AppNavigation`), alongside the sidebar, not a separate popup window. /// `AppNavigation`), alongside the sidebar, not a separate popup window.
/// Replaces the old `Settings {}` scene / `PreferencesView` and folds in /// Replaces the old `Settings {}` scene / `PreferencesView` and folds in
/// what used to be the standalone "About Outpost" window's content too, so /// what used to be the standalone "About Outpost" window's content too, so
/// everything about the app lives in one place. /// 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 { struct SettingsView: View {
let onDone: () -> Void let onDone: () -> Void
@@ -14,6 +44,7 @@ struct SettingsView: View {
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@State private var selectedSection: SettingsSection? = .appearance
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@State private var storageSummary: CacheStorageSummary? @State private var storageSummary: CacheStorageSummary?
@State private var pendingOperations: [PendingOperationSummary] = [] @State private var pendingOperations: [PendingOperationSummary] = []
@@ -26,25 +57,17 @@ struct SettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
header header
Divider() Divider()
HStack(spacing: 0) {
sectionList
.frame(width: 190)
Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { sectionDetail(selectedSection ?? .appearance)
// Appearance and Account are short let them sit .padding(28)
// side-by-side when there's room instead of each .frame(maxWidth: .infinity, alignment: .leading)
// 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 .frame(maxWidth: .infinity, maxHeight: .infinity)
aboutSection
} }
.padding(24)
.frame(maxWidth: 900)
}
.frame(maxWidth: .infinity)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background) .background(.background)
@@ -63,10 +86,34 @@ struct SettingsView: View {
.padding(20) .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 // MARK: - Appearance
private var appearanceSection: some View { private var appearanceDetail: some View {
section("Appearance", icon: "paintbrush") { VStack(alignment: .leading, spacing: 16) {
sectionHeader(.appearance)
Picker("Appearance", selection: $appearance) { Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option) Text(option.label).tag(option)
@@ -74,13 +121,15 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.labelsHidden() .labelsHidden()
.frame(maxWidth: 320)
} }
} }
// MARK: - Account // MARK: - Account
private var accountSection: some View { private var accountDetail: some View {
section("Account", icon: "person.crop.circle") { VStack(alignment: .leading, spacing: 16) {
sectionHeader(.account)
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
labeledRow("Signed in as", session.userName ?? "") labeledRow("Signed in as", session.userName ?? "")
if let email = session.userEmail { if let email = session.userEmail {
@@ -89,28 +138,28 @@ struct SettingsView: View {
if let teamName = session.teamName { if let teamName = session.teamName {
labeledRow("Workspace", teamName) labeledRow("Workspace", teamName)
} }
}
.frame(maxWidth: 420)
Button("Log Out…", role: .destructive) { Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true isShowingLogoutConfirmation = true
} }
.padding(.top, 4)
}
} }
} }
// MARK: - Offline & Sync // MARK: - Offline & Sync
private var offlineSyncSection: some View { private var offlineSyncDetail: some View {
section("Offline & Sync", icon: "arrow.triangle.2.circlepath") { VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 16) { sectionHeader(.offlineSync)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: 480, alignment: .leading)
Divider()
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
@@ -121,6 +170,7 @@ struct SettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: 480, alignment: .leading)
if isFullLocalSyncEnabled { if isFullLocalSyncEnabled {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -142,11 +192,16 @@ struct SettingsView: View {
} }
Divider() Divider()
.frame(maxWidth: 480)
storageRow storageRow
.frame(maxWidth: 480, alignment: .leading)
Divider() Divider()
.frame(maxWidth: 480)
pendingOperationsRow pendingOperationsRow
} .frame(maxWidth: 480, alignment: .leading)
} }
} }
@@ -246,25 +301,16 @@ struct SettingsView: View {
// MARK: - About // MARK: - About
private var aboutSection: some View { private var aboutDetail: some View {
section("About", icon: "info.circle") { VStack(alignment: .leading, spacing: 16) {
sectionHeader(.about)
AboutInfoView() AboutInfoView()
.frame(maxWidth: 420, alignment: .leading)
} }
} }
// MARK: - Helpers // 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 { private func labeledRow(_ label: String, _ value: String) -> some View {
HStack { HStack {
Text(label) Text(label)