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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user