Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Packages/TossKit/Sources/TossKit/Models/TossLayoutMode.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions toss/Services/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
66 changes: 66 additions & 0 deletions toss/Views/Tosses/TossGridView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
76 changes: 76 additions & 0 deletions toss/Views/Tosses/TossTableView.swift
Original file line number Diff line number Diff line change
@@ -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<Toss>] = [
KeyPathComparator(\.createdAt, order: .reverse)
]
@State private var selection = Set<Toss.ID>()

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
115 changes: 62 additions & 53 deletions toss/Views/Tosses/TossesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -195,14 +212,6 @@ struct TossesView: View {
#endif
}
}

private var spacing: CGFloat {
#if os(macOS)
20
#else
16
#endif
}
}

private struct CloudSyncIndicator: View {
Expand Down
Loading