From 19eb1bae69b6abe2dcdb7c0390bac1c412fed068 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:29:51 +0100 Subject: [PATCH] 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. --- Outpost/Features/Account/SettingsView.swift | 198 ++++++++++++-------- 1 file changed, 122 insertions(+), 76 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 76f18b5..3f939f8 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -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(_ 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)