commit 882b63a36136e69f693352c318ce41dfde172d85
parent 25df50d33fc67a4b869bdae65a2f623742a64081
Author: Michael Camilleri <[email protected]>
Date: Sat, 23 May 2026 06:45:33 +0900
Lay out the game list as a card grid on iPad
The game list rendered as a single-column List on every size class, which left
the iPad screen mostly empty. On regular-width devices it now flows as a grid
of cards: each card carries the same row content — the 60×60 thumbnail, title,
metadata, last-updated, overflow menu — at a fixed 88pt height, so two or three
cards fit per line in portrait/landscape. iPhone keeps the existing List
unchanged.
The grid uses a ScrollView wrapping a LazyVStack of Sections (Invited / In
Progress / Completed) with pinned headers; each section's cards live in a
LazyVGrid with adaptive columns at a 320pt minimum. The card chrome matches an
inset-grouped list cell — secondarySystemGroupedBackground on a
systemGroupedBackground backdrop, 12pt corners — and the existing 'Load More'
control sits beneath Completed.
Each card is one Button so the pressed highlight covers the full card, with the
overflow Menu layered as a sibling .overlay rather than nested inside the
button. Keeping them siblings means tapping the ellipsis opens the menu instead
of also firing the card's navigation action. The Menu's contents (Share,
Resume, Resign, and Leave or Delete) are extracted into a shared
GameOverflowMenu used by both GameRowView on iPhone and the new GameCardView on
iPad, so the action set stays in one place.
During the implementation, it was realised that invitations needed reworking.
They become cards too — without a thumbnail slot, since invites have no grid —
but Decline and Block existed only as List swipe actions, for which a LazyVGrid
has no equivalent. inviteMenu now exposes them as a worded 'More' Menu styled
.bordered/.controlSize (.small) so it pairs visually with Accept; a worded
sibling to Accept, rather than an ellipsis, makes the alternative path
discoverable on what is fundamentally a decision row. The same 'More' button is
added to the iPhone invitations row; its swipe actions remain alongside.
It is noted that cards have a fixed height, so at very large Dynamic Type sizes
the three text lines can exceed the available content area and clip. That
trades flexibility for a uniform grid and is worth revisiting if it shows up in
practice.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 373 insertions(+), 55 deletions(-)
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -11,6 +11,7 @@ struct GameListView: View {
@Environment(\.managedObjectContext) private var viewContext
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest(
sortDescriptors: [],
animation: .default
@@ -180,10 +181,55 @@ struct GameListView: View {
return !blockedIDs.contains(inviter)
}
+ Group {
+ if horizontalSizeClass == .regular {
+ gridLayout(
+ invites: visibleInvites,
+ inProgress: inProgress,
+ completed: visibleCompleted,
+ hasMore: hasMore,
+ usesRoomierType: usesRoomierType
+ )
+ } else {
+ listLayout(
+ invites: visibleInvites,
+ inProgress: inProgress,
+ completed: visibleCompleted,
+ hasMore: hasMore,
+ usesRoomierType: usesRoomierType
+ )
+ }
+ }
+ .overlay {
+ if games.isEmpty {
+ ContentUnavailableView {
+ Label("No Puzzles", systemImage: "square.grid.3x3")
+ } description: {
+ Text("Tap the + button to start a new puzzle, or pull down to refresh.")
+ }
+ }
+ }
+ .onChange(of: completed.count) { oldCount, newCount in
+ if newCount > oldCount {
+ completedVisibleCount += (newCount - oldCount)
+ }
+ }
+ }
+
+ // MARK: - List layout (compact width / iPhone)
+
+ @ViewBuilder
+ private func listLayout(
+ invites: [InviteEntity],
+ inProgress: [GameSummary],
+ completed: [GameSummary],
+ hasMore: Bool,
+ usesRoomierType: Bool
+ ) -> some View {
List {
- if !visibleInvites.isEmpty {
+ if !invites.isEmpty {
Section {
- ForEach(visibleInvites, id: \.objectID) { invite in
+ ForEach(invites, id: \.objectID) { invite in
inviteRow(for: invite)
}
} header: {
@@ -203,53 +249,185 @@ struct GameListView: View {
if !completed.isEmpty {
Section {
- ForEach(visibleCompleted) { game in
+ ForEach(completed) { game in
rowView(for: game, usesRoomierType: usesRoomierType)
}
} header: {
Text("Completed")
} footer: {
if hasMore {
- HStack {
- Spacer()
- Button {
- withAnimation(.easeInOut(duration: 0.25)) {
- completedVisibleCount += Self.completedPageSize
- }
- } label: {
- Text("Load More")
- .font(.subheadline.weight(.semibold))
- .foregroundColor(.secondary)
- .padding(.horizontal, 18)
- .padding(.vertical, 8)
- .background(Color(.tertiarySystemFill), in: Capsule())
- }
- .buttonStyle(.plain)
- .textCase(nil)
- Spacer()
- }
- .padding(.top, 8)
+ loadMoreButton
}
}
}
}
- .overlay {
- if games.isEmpty {
- ContentUnavailableView {
- Label("No Puzzles", systemImage: "square.grid.3x3")
- } description: {
- Text("Tap the + button to start a new puzzle, or pull down to refresh.")
+ .refreshable {
+ await onRefresh()
+ }
+ }
+
+ // MARK: - Grid layout (regular width / iPad)
+
+ private var gridColumns: [GridItem] {
+ [GridItem(.adaptive(minimum: 320), spacing: 12)]
+ }
+
+ @ViewBuilder
+ private func gridLayout(
+ invites: [InviteEntity],
+ inProgress: [GameSummary],
+ completed: [GameSummary],
+ hasMore: Bool,
+ usesRoomierType: Bool
+ ) -> some View {
+ ScrollView {
+ LazyVStack(spacing: 8, pinnedViews: [.sectionHeaders]) {
+ if !invites.isEmpty {
+ Section {
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ForEach(invites, id: \.objectID) { invite in
+ inviteCard(for: invite)
+ }
+ }
+ .padding(.horizontal)
+ } header: {
+ gridSectionHeader("Invited")
+ }
+ }
+
+ if !inProgress.isEmpty {
+ Section {
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ForEach(inProgress) { game in
+ gameCard(for: game, usesRoomierType: usesRoomierType)
+ }
+ }
+ .padding(.horizontal)
+ } header: {
+ gridSectionHeader("In Progress")
+ }
+ }
+
+ if !completed.isEmpty {
+ Section {
+ LazyVGrid(columns: gridColumns, spacing: 12) {
+ ForEach(completed) { game in
+ gameCard(for: game, usesRoomierType: usesRoomierType)
+ }
+ }
+ .padding(.horizontal)
+
+ if hasMore {
+ loadMoreButton
+ .padding(.horizontal)
+ }
+ } header: {
+ gridSectionHeader("Completed")
+ }
}
}
+ .padding(.vertical, 8)
}
+ .background(Color(.systemGroupedBackground))
.refreshable {
await onRefresh()
}
- .onChange(of: completed.count) { oldCount, newCount in
- if newCount > oldCount {
- completedVisibleCount += (newCount - oldCount)
+ }
+
+ private func gridSectionHeader(_ title: String) -> some View {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(Color(.systemGroupedBackground))
+ }
+
+ private var loadMoreButton: some View {
+ HStack {
+ Spacer()
+ Button {
+ withAnimation(.easeInOut(duration: 0.25)) {
+ completedVisibleCount += Self.completedPageSize
+ }
+ } label: {
+ Text("Load More")
+ .font(.subheadline.weight(.semibold))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 8)
+ .background(Color(.tertiarySystemFill), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .textCase(nil)
+ Spacer()
+ }
+ .padding(.top, 8)
+ }
+
+ private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View {
+ GameCardView(
+ game: game,
+ shareController: shareController,
+ usesRoomierType: usesRoomierType,
+ onResume: { navigationPath.append(game.id) },
+ onLeave: { leaveTarget = game },
+ onResign: { resignTarget = game },
+ onDelete: { deleteTarget = game }
+ )
+ }
+
+ @ViewBuilder
+ private func inviteCard(for invite: InviteEntity) -> some View {
+ let inviter = (invite.inviterName?.isEmpty == false) ? invite.inviterName! : "A player"
+ let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Text("Invited by \(inviter)")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ if acceptingInviteID == invite.objectID {
+ ProgressView()
+ } else {
+ Button("Accept") { Task { await accept(invite) } }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ inviteMenu(for: invite)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .frame(height: CardMetrics.height)
+ .background(
+ Color(.secondarySystemGroupedBackground),
+ in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+ )
+ }
+
+ private func inviteMenu(for invite: InviteEntity) -> some View {
+ Menu {
+ Button { decline(invite) } label: {
+ Label("Decline", systemImage: "xmark")
+ }
+ Button(role: .destructive) { blockTarget = invite } label: {
+ Label("Block", systemImage: "hand.raised")
}
+ } label: {
+ Text("More")
+ .foregroundStyle(.primary)
}
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .tint(.secondary)
+ .compositingGroup()
}
@ViewBuilder
@@ -271,6 +449,7 @@ struct GameListView: View {
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
+ inviteMenu(for: invite)
}
.swipeActions(edge: .trailing) {
Button("Decline") { decline(invite) }
@@ -410,36 +589,114 @@ private struct GameRowView: View {
}
}
Spacer()
- Menu {
- Button {
- isShowingShareSheet = true
- } label: {
- Label("Share", systemImage: "square.and.arrow.up")
+ GameOverflowMenu(
+ game: game,
+ onShare: { isShowingShareSheet = true },
+ onResume: onResume,
+ onLeave: onLeave,
+ onResign: onResign,
+ onDelete: onDelete
+ )
+ }
+ .padding(.vertical, 4)
+ .sheet(isPresented: $isShowingShareSheet) {
+ GameShareSheet(
+ gameID: game.id,
+ title: game.title,
+ shareController: shareController
+ )
+ }
+ }
+}
+
+// MARK: - Card (regular width)
+
+private enum CardMetrics {
+ static let height: CGFloat = 88
+ static let cornerRadius: CGFloat = 12
+}
+
+/// Tappable card used in the iPad grid layout. The whole card is one
+/// `Button` (so the pressed-state highlight covers the full card), and the
+/// overflow `Menu` is layered as an `.overlay` rather than nested inside the
+/// button — keeping them siblings means tapping the ellipsis opens the menu
+/// instead of also firing the navigation action.
+private struct GameCardView: View {
+ let game: GameSummary
+ let shareController: ShareController
+ let usesRoomierType: Bool
+ var onResume: () -> Void = {}
+ var onLeave: () -> Void = {}
+ var onResign: () -> Void = {}
+ var onDelete: () -> Void = {}
+ @State private var isShowingShareSheet = false
+
+ var body: some View {
+ let showsUnreadBadge = game.hasUnreadOtherMoves
+
+ Button(action: onResume) {
+ HStack(spacing: 12) {
+ GridThumbnailView(
+ width: game.gridWidth,
+ height: game.gridHeight,
+ cells: game.thumbnailCells
+ )
+ .overlay(alignment: .topTrailing) {
+ if showsUnreadBadge {
+ Circle()
+ .fill(.red)
+ .frame(width: 14, height: 14)
+ .overlay(
+ Circle()
+ .stroke(.background, lineWidth: 2)
+ )
+ .offset(x: 5, y: -5)
+ .accessibilityLabel("Unseen changes")
+ }
}
- .disabled(!game.isOwned)
- Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") }
- Section {
- Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") }
- if !game.isOwned && game.isShared {
- Button(role: .destructive) { onLeave() } label: {
- Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
- }
- } else {
- Button(role: .destructive) { onDelete() } label: {
- Label("Delete", systemImage: "trash")
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Text(game.title)
+ .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .truncationMode(.tail)
+ if game.isShared {
+ Image(systemName: "person.2.fill")
+ .font(.caption)
+ .foregroundStyle(.secondary)
}
}
+ GameMetadataView(
+ puzzleDate: game.puzzleDate,
+ publisher: game.publisher,
+ usesRoomierType: usesRoomierType
+ )
+ if let date = game.updatedAt {
+ LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
+ }
}
- } label: {
- Image(systemName: "ellipsis")
- .font(.body)
- .frame(width: 32, height: 32)
- .contentShape(Rectangle())
+ Spacer(minLength: 0)
+ // Reserve room for the overflow menu, which is layered as an
+ // overlay so its taps don't fall through to this button.
+ Color.clear.frame(width: 32, height: 32)
}
- .tint(.secondary)
- .compositingGroup()
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .frame(height: CardMetrics.height)
+ }
+ .buttonStyle(CardButtonStyle())
+ .overlay(alignment: .trailing) {
+ GameOverflowMenu(
+ game: game,
+ onShare: { isShowingShareSheet = true },
+ onResume: onResume,
+ onLeave: onLeave,
+ onResign: onResign,
+ onDelete: onDelete
+ )
+ .padding(.trailing, 12)
}
- .padding(.vertical, 4)
.sheet(isPresented: $isShowingShareSheet) {
GameShareSheet(
gameID: game.id,
@@ -450,6 +707,67 @@ private struct GameRowView: View {
}
}
+private struct CardButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .background(
+ Color(.secondarySystemGroupedBackground),
+ in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+ )
+ .overlay {
+ if configuration.isPressed {
+ RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+ .fill(Color.primary.opacity(0.06))
+ }
+ }
+ .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius))
+ }
+}
+
+// MARK: - Shared overflow menu
+
+private struct GameOverflowMenu: View {
+ let game: GameSummary
+ var onShare: () -> Void
+ var onResume: () -> Void
+ var onLeave: () -> Void
+ var onResign: () -> Void
+ var onDelete: () -> Void
+
+ var body: some View {
+ Menu {
+ Button { onShare() } label: {
+ Label("Share", systemImage: "square.and.arrow.up")
+ }
+ .disabled(!game.isOwned)
+ Button { onResume() } label: {
+ Label("Resume", systemImage: "square.and.pencil")
+ }
+ Section {
+ Button(role: .destructive) { onResign() } label: {
+ Label("Resign", systemImage: "flag")
+ }
+ if !game.isOwned && game.isShared {
+ Button(role: .destructive) { onLeave() } label: {
+ Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
+ }
+ } else {
+ Button(role: .destructive) { onDelete() } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ .font(.body)
+ .frame(width: 32, height: 32)
+ .contentShape(Rectangle())
+ }
+ .tint(.secondary)
+ .compositingGroup()
+ }
+}
+
private struct LastUpdatedView: View {
let date: Date
let usesRoomierType: Bool