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 {