From 631939754e81df15a847566bf259097539e6f148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Vidovic=CC=8C?= Date: Tue, 14 Apr 2026 11:23:57 +0200 Subject: [PATCH] feat(ui): grid/table layout toggle for tosses list Add a macOS-only segmented toolbar picker that toggles the Tosses list between the existing card grid and a new SwiftUI Table with sortable Content / Type / Created columns. The choice persists via @AppStorage on AppSettings. The grid rendering is extracted into a standalone TossGridView, and a new macOS-only TossTableView drives interactive sorting via an in-memory [KeyPathComparator], selection via Set, and a selection-aware context menu with a primaryAction that reuses the existing selectedToss sheet flow for row activation. iOS stays on the grid layout and does not show the picker. SwiftUI Table collapses to a single header-less column at compact iPhone width per Apple's documented behavior, so a real multi-column table is only offered on macOS. The architecture keeps the #if os(macOS) dispatcher trivial to relax into a horizontal-size-class check if iPad support is added later. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TossKit/Models/TossLayoutMode.swift | 13 ++ toss/Services/AppSettings.swift | 8 ++ toss/Views/Tosses/TossGridView.swift | 66 ++++++++++ toss/Views/Tosses/TossTableView.swift | 76 ++++++++++++ toss/Views/Tosses/TossesView.swift | 115 ++++++++++-------- 5 files changed, 225 insertions(+), 53 deletions(-) create mode 100644 Packages/TossKit/Sources/TossKit/Models/TossLayoutMode.swift create mode 100644 toss/Views/Tosses/TossGridView.swift create mode 100644 toss/Views/Tosses/TossTableView.swift diff --git a/Packages/TossKit/Sources/TossKit/Models/TossLayoutMode.swift b/Packages/TossKit/Sources/TossKit/Models/TossLayoutMode.swift new file mode 100644 index 0000000..37109a9 --- /dev/null +++ b/Packages/TossKit/Sources/TossKit/Models/TossLayoutMode.swift @@ -0,0 +1,13 @@ +// +// TossLayoutMode.swift +// TossKit +// +// User-selectable layout for the Tosses list. +// + +import Foundation + +public enum TossLayoutMode: String, CaseIterable, Sendable { + case grid + case table +} diff --git a/toss/Services/AppSettings.swift b/toss/Services/AppSettings.swift index fdc77e2..2ede74d 100644 --- a/toss/Services/AppSettings.swift +++ b/toss/Services/AppSettings.swift @@ -7,8 +7,16 @@ import Foundation import SwiftUI +import TossKit @MainActor class AppSettings: ObservableObject { @AppStorage("biometric_enabled") var isBiometricEnabled: Bool = false + + @AppStorage("layout_mode") private var layoutModeRaw: String = TossLayoutMode.grid.rawValue + + var layoutMode: TossLayoutMode { + get { TossLayoutMode(rawValue: layoutModeRaw) ?? .grid } + set { layoutModeRaw = newValue.rawValue } + } } diff --git a/toss/Views/Tosses/TossGridView.swift b/toss/Views/Tosses/TossGridView.swift new file mode 100644 index 0000000..33f787a --- /dev/null +++ b/toss/Views/Tosses/TossGridView.swift @@ -0,0 +1,66 @@ +// +// TossGridView.swift +// toss +// +// Card-grid presentation of a toss list. Extracted from TossesView so the +// parent can dispatch between this and TossTableView based on the user's +// preferred layout mode. +// + +import SwiftUI +import TossKit + +struct TossGridView: View { + let tosses: [Toss] + let onTap: (Toss) -> Void + let onCopy: (Toss) -> Void + let onDelete: (Toss) -> Void + #if os(macOS) + @Binding var isAddingToss: Bool + #endif + + private var columns: [GridItem] { + #if os(macOS) + [GridItem(.adaptive(minimum: 220, maximum: 330), spacing: spacing)] + #else + [GridItem(.flexible(), spacing: spacing), GridItem(.flexible(), spacing: spacing)] + #endif + } + + private var spacing: CGFloat { + #if os(macOS) + 20 + #else + 16 + #endif + } + + var body: some View { + LazyVGrid(columns: columns, spacing: spacing) { + #if os(macOS) + AddTossCard(isEditing: $isAddingToss) + #endif + + ForEach(tosses) { toss in + TossCard(toss: toss) + .contextMenu { + Button { + onCopy(toss) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + + Button(role: .destructive) { + onDelete(toss) + } label: { + Label("Delete", systemImage: "trash") + } + } + .onTapGesture { + onTap(toss) + } + } + } + .padding() + } +} diff --git a/toss/Views/Tosses/TossTableView.swift b/toss/Views/Tosses/TossTableView.swift new file mode 100644 index 0000000..f629245 --- /dev/null +++ b/toss/Views/Tosses/TossTableView.swift @@ -0,0 +1,76 @@ +// +// TossTableView.swift +// toss +// +// Sortable multi-column table presentation of a toss list. macOS only — +// SwiftUI `Table` collapses to a single column on compact iPhone width, +// so iOS stays on the card grid. +// + +#if os(macOS) + + import SwiftUI + import TossKit + + struct TossTableView: View { + let tosses: [Toss] + let onActivate: (Toss) -> Void + let onCopy: (Toss) -> Void + let onDelete: (Toss) -> Void + + @State private var sortOrder: [KeyPathComparator] = [ + KeyPathComparator(\.createdAt, order: .reverse) + ] + @State private var selection = Set() + + private var sortedTosses: [Toss] { + tosses.sorted(using: sortOrder) + } + + var body: some View { + Table(sortedTosses, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Content", value: \.content) { toss in + HStack(spacing: 8) { + Image(systemName: toss.type == .link ? "link" : "text.alignleft") + .foregroundStyle(.secondary) + Text(toss.metadataTitle ?? toss.content) + .lineLimit(1) + .truncationMode(.tail) + } + } + TableColumn("Type", value: \.typeRawValue) { toss in + Text(toss.type.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + .width(min: 60, ideal: 80, max: 100) + TableColumn("Created", value: \.createdAt) { toss in + Text(toss.createdAt, format: .dateTime.month().day().year().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + .width(min: 140, ideal: 180, max: 220) + } + .contextMenu(forSelectionType: Toss.ID.self) { ids in + if let id = ids.first, let toss = sortedTosses.first(where: { $0.id == id }) { + Button { + onCopy(toss) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + + Button(role: .destructive) { + onDelete(toss) + } label: { + Label("Delete", systemImage: "trash") + } + } + } primaryAction: { ids in + if let id = ids.first, let toss = sortedTosses.first(where: { $0.id == id }) { + onActivate(toss) + } + } + } + } + +#endif diff --git a/toss/Views/Tosses/TossesView.swift b/toss/Views/Tosses/TossesView.swift index c6c1f27..2390269 100644 --- a/toss/Views/Tosses/TossesView.swift +++ b/toss/Views/Tosses/TossesView.swift @@ -18,65 +18,31 @@ import TossKit struct TossesView: View { @Query(sort: \Toss.createdAt, order: .reverse) private var tosses: [Toss] @Environment(\.modelContext) private var modelContext + @EnvironmentObject private var appSettings: AppSettings + @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor @StateObject private var viewModel = TossesViewModel() - @EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor @State private var showingAddToss = false @State private var editingToss: Toss? @State private var selectedToss: Toss? @State private var isAddingToss = false @State private var searchText = "" - private var columns: [GridItem] { - #if os(macOS) - [GridItem(.adaptive(minimum: 220, maximum: 330), spacing: spacing)] - #else - [GridItem(.flexible(), spacing: spacing), GridItem(.flexible(), spacing: spacing)] - #endif - } - private var filteredTosses: [Toss] { viewModel.filteredTosses(from: tosses) } var body: some View { NavigationStack { - ScrollView(showsIndicators: false) { - LazyVGrid(columns: columns, spacing: spacing) { - #if os(macOS) - AddTossCard(isEditing: $isAddingToss) - #endif - - ForEach(filteredTosses) { toss in - TossCard(toss: toss) - .contextMenu { - Button { - copyToClipboard(toss.content) - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - - Button(role: .destructive) { - deleteToss(toss) - } label: { - Label("Delete", systemImage: "trash") - } - } - .onTapGesture { - handleTossTap(toss) - } - } - } - .padding() - } - .scrollIndicators(.hidden, axes: [.vertical, .horizontal]) - #if os(iOS) - .background(Color(UIColor.systemBackground)) - .navigationBarTitleDisplayMode(.inline) - #else - .background(.background) - #endif - .navigationTitle("Tosses") + content + .scrollIndicators(.hidden, axes: [.vertical, .horizontal]) + #if os(iOS) + .background(Color(UIColor.systemBackground)) + .navigationBarTitleDisplayMode(.inline) + #else + .background(.background) + #endif + .navigationTitle("Tosses") .searchable(text: $searchText, prompt: "Search tosses...") .onAppear { viewModel.scheduleSearchDebounce(searchText) @@ -96,6 +62,20 @@ struct TossesView: View { CloudSyncIndicator(state: cloudSyncMonitor.state) } } + ToolbarItem(placement: .primaryAction) { + Picker( + "Layout", + selection: Binding( + get: { appSettings.layoutMode }, + set: { appSettings.layoutMode = $0 } + ) + ) { + Label("Grid", systemImage: "square.grid.2x2").tag(TossLayoutMode.grid) + Label("Table", systemImage: "tablecells").tag(TossLayoutMode.table) + } + .pickerStyle(.segmented) + .labelsHidden() + } ToolbarItem(placement: .primaryAction) { Button { showingAddToss = true @@ -156,6 +136,43 @@ struct TossesView: View { } } + @ViewBuilder + private var content: some View { + #if os(macOS) + switch appSettings.layoutMode { + case .grid: + ScrollView(showsIndicators: false) { + TossGridView( + tosses: filteredTosses, + onTap: handleTossTap, + onCopy: { copyToClipboard($0.content) }, + onDelete: deleteToss, + isAddingToss: $isAddingToss + ) + } + case .table: + TossTableView( + tosses: filteredTosses, + onActivate: { toss in + selectedToss = toss + isAddingToss = false + }, + onCopy: { copyToClipboard($0.content) }, + onDelete: deleteToss + ) + } + #else + ScrollView(showsIndicators: false) { + TossGridView( + tosses: filteredTosses, + onTap: handleTossTap, + onCopy: { copyToClipboard($0.content) }, + onDelete: deleteToss + ) + } + #endif + } + private func deleteToss(_ toss: Toss) { withAnimation { modelContext.delete(toss) @@ -195,14 +212,6 @@ struct TossesView: View { #endif } } - - private var spacing: CGFloat { - #if os(macOS) - 20 - #else - 16 - #endif - } } private struct CloudSyncIndicator: View {