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