crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

GameListView.swift (36224B)


      1 import CoreData
      2 import SwiftUI
      3 
      4 struct GameListView: View {
      5     let store: GameStore
      6     let shareController: ShareController
      7     let onRefresh: () async -> Void
      8     let onAppear: () async -> Void
      9     let onDisappear: () -> Void
     10     @Binding var navigationPath: NavigationPath
     11 
     12     @Environment(\.managedObjectContext) private var viewContext
     13     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     14     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
     15     @FetchRequest(
     16         sortDescriptors: [],
     17         animation: .default
     18     )
     19     private var games: FetchedResults<GameEntity>
     20 
     21     @FetchRequest(
     22         sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)],
     23         predicate: NSPredicate(format: "status == %@", "pending"),
     24         animation: .default
     25     )
     26     private var pendingInvites: FetchedResults<InviteEntity>
     27 
     28     @FetchRequest(
     29         sortDescriptors: [],
     30         predicate: NSPredicate(format: "isBlocked == YES")
     31     )
     32     private var blockedFriends: FetchedResults<FriendEntity>
     33 
     34     @Environment(\.acceptInvite) private var acceptInvite
     35     @Environment(\.declineInvite) private var declineInvite
     36     @Environment(\.blockFriend) private var blockFriend
     37     @Environment(\.sendResignPings) private var sendResignPings
     38     @Environment(PlayerPreferences.self) private var preferences
     39     @Environment(AnnouncementCenter.self) private var announcements
     40     @State private var acceptingInviteID: NSManagedObjectID?
     41     @State private var blockTarget: InviteEntity?
     42 
     43     @State private var showingNewGame = false
     44     @State private var showingSettings = false
     45     @State private var showingFriends = false
     46     @State private var deleteTarget: GameSummary?
     47     @State private var resignTarget: GameSummary?
     48     @State private var leaveTarget: GameSummary?
     49     @State private var leaveError: Error?
     50     @State private var showingNamePrompt = false
     51     @State private var nameDraft = ""
     52     @State private var summaryCache = GameSummaryCache()
     53     @State private var completedVisibleCount = completedPageSize
     54 
     55     private static let completedPageSize = 7
     56 
     57     var body: some View {
     58         GeometryReader { geometry in
     59             VStack(spacing: 0) {
     60                 if let announcement = announcements.currentGlobal() {
     61                     AnnouncementBanner(announcement: announcement) {
     62                         announcements.dismiss(id: announcement.id)
     63                     }
     64                     .padding(.horizontal)
     65                     .padding(.top, 8)
     66                     .transition(.move(edge: .top).combined(with: .opacity))
     67                 }
     68                 content(usesRoomierType: usesRoomierType(for: geometry.size))
     69             }
     70             .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal())
     71         }
     72         .navigationTitle("")
     73         .navigationBarTitleDisplayMode(.inline)
     74         .toolbar {
     75             ToolbarItem(placement: .topBarLeading) {
     76                 Button {
     77                     showingSettings = true
     78                 } label: {
     79                     Image(systemName: "gearshape")
     80                 }
     81             }
     82             ToolbarItem(placement: .topBarTrailing) {
     83                 Button {
     84                     showingFriends = true
     85                 } label: {
     86                     Image(systemName: "person.2")
     87                 }
     88             }
     89             ToolbarSpacer(.fixed, placement: .topBarTrailing)
     90             ToolbarItem(placement: .topBarTrailing) {
     91                 Button {
     92                     showingNewGame = true
     93                 } label: {
     94                     Image(systemName: "plus")
     95                 }
     96             }
     97         }
     98         .sheet(isPresented: $showingSettings) {
     99             SettingsView()
    100         }
    101         .sheet(isPresented: $showingFriends) {
    102             FriendsView()
    103         }
    104         .sheet(isPresented: $showingNewGame) {
    105             NewGameSheet(store: store) { gameID in
    106                 navigationPath.append(gameID)
    107             }
    108         }
    109         .task {
    110             await onAppear()
    111         }
    112         .onDisappear {
    113             onDisappear()
    114         }
    115         .alert("Resign Puzzle?", isPresented: .init(
    116             get: { resignTarget != nil },
    117             set: { if !$0 { resignTarget = nil } }
    118         )) {
    119             Button("Resign", role: .destructive) {
    120                 if let target = resignTarget {
    121                     do {
    122                         try store.resignGame(id: target.id)
    123                         let id = target.id
    124                         Task { await sendResignPings?(id) }
    125                     } catch {
    126                         announcements.post(Announcement(
    127                             id: Self.destructiveActionErrorID,
    128                             scope: .global,
    129                             severity: .error,
    130                             title: "Resigning Failed",
    131                             body: error.localizedDescription,
    132                             dismissal: .manual
    133                         ))
    134                     }
    135                 }
    136             }
    137             Button("Cancel", role: .cancel) {}
    138         } message: {
    139             if let target = resignTarget {
    140                 Text("This will reveal all answers for \"\(target.title)\".")
    141             }
    142         }
    143         .alert("Leave Puzzle?", isPresented: .init(
    144             get: { leaveTarget != nil },
    145             set: { if !$0 { leaveTarget = nil } }
    146         )) {
    147             Button("Leave", role: .destructive) {
    148                 if let target = leaveTarget {
    149                     Task { await leaveShare(game: target) }
    150                 }
    151             }
    152             Button("Cancel", role: .cancel) {}
    153         } message: {
    154             if let target = leaveTarget {
    155                 Text("You will lose access to \"\(target.title)\".")
    156             }
    157         }
    158         .alert("Delete Puzzle?", isPresented: .init(
    159             get: { deleteTarget != nil },
    160             set: { if !$0 { deleteTarget = nil } }
    161         )) {
    162             Button("Delete", role: .destructive) {
    163                 if let target = deleteTarget {
    164                     do {
    165                         try store.deleteGame(id: target.id)
    166                     } catch {
    167                         announcements.post(Announcement(
    168                             id: Self.destructiveActionErrorID,
    169                             scope: .global,
    170                             severity: .error,
    171                             title: "Deleting Failed",
    172                             body: error.localizedDescription,
    173                             dismissal: .manual
    174                         ))
    175                     }
    176                 }
    177             }
    178             Button("Cancel", role: .cancel) {}
    179         } message: {
    180             if let target = deleteTarget {
    181                 if target.isOwned && target.isShared {
    182                     Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.")
    183                 } else {
    184                     Text("This will permanently delete \"\(target.title)\" and all progress.")
    185                 }
    186             }
    187         }
    188         .alert("Block This Player?", isPresented: .init(
    189             get: { blockTarget != nil },
    190             set: { if !$0 { blockTarget = nil } }
    191         )) {
    192             Button("Block", role: .destructive) {
    193                 if let target = blockTarget, let authorID = target.inviterAuthorID {
    194                     Task { await blockFriend?(authorID) }
    195                 }
    196             }
    197             Button("Cancel", role: .cancel) {}
    198         } message: {
    199             let name = blockTarget?.resolvedInviterName ?? "this player"
    200             Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.")
    201         }
    202         .alert("Set Profile Name", isPresented: $showingNamePrompt) {
    203             TextField("Name", text: $nameDraft)
    204                 .textInputAutocapitalization(.never)
    205                 .autocorrectionDisabled()
    206             Button("Cancel", role: .cancel) {}
    207             Button("Save") {
    208                 let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
    209                 if !trimmed.isEmpty {
    210                     preferences.name = trimmed
    211                     nameDraft = trimmed
    212                 }
    213             }
    214             .keyboardShortcut(.defaultAction)
    215         } message: {
    216             Text("Enter the name other players will see.")
    217         }
    218     }
    219 
    220     @ViewBuilder
    221     private func content(usesRoomierType: Bool) -> some View {
    222         let summaries = games.compactMap { summaryCache.summary(for: $0) }
    223         let inProgress = summaries
    224             .filter { $0.completedAt == nil && !$0.isAccessRevoked }
    225             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    226         let revoked = summaries
    227             .filter { $0.isAccessRevoked }
    228             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    229         let completed = summaries
    230             .filter { $0.completedAt != nil && !$0.isAccessRevoked }
    231             .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
    232         let visibleCount = min(completedVisibleCount, completed.count)
    233         let visibleCompleted = Array(completed.prefix(visibleCount))
    234         let hasMore = visibleCount < completed.count
    235 
    236         let blockedIDs = Set(blockedFriends.compactMap { $0.authorID })
    237         let visibleInvites = pendingInvites.filter {
    238             guard let inviter = $0.inviterAuthorID else { return true }
    239             return !blockedIDs.contains(inviter)
    240         }
    241 
    242         Group {
    243             if horizontalSizeClass == .regular {
    244                 gridLayout(
    245                     invites: visibleInvites,
    246                     inProgress: inProgress,
    247                     revoked: revoked,
    248                     completed: visibleCompleted,
    249                     hasMore: hasMore,
    250                     usesRoomierType: usesRoomierType
    251                 )
    252             } else {
    253                 listLayout(
    254                     invites: visibleInvites,
    255                     inProgress: inProgress,
    256                     revoked: revoked,
    257                     completed: visibleCompleted,
    258                     hasMore: hasMore,
    259                     usesRoomierType: usesRoomierType
    260                 )
    261             }
    262         }
    263         .overlay {
    264             if games.isEmpty {
    265                 if preferences.hasName {
    266                     ContentUnavailableView {
    267                         Label("No Puzzles", systemImage: "square.grid.3x3")
    268                     } description: {
    269                         Text("Tap the + button to start a new puzzle, or pull down to refresh.")
    270                     }
    271                 } else {
    272                     ContentUnavailableView {
    273                         Label("Set Your Profile Name", systemImage: "person.text.rectangle")
    274                     } description: {
    275                         Text("Choose the name other players will see.")
    276                     } actions: {
    277                         Button {
    278                             nameDraft = ""
    279                             showingNamePrompt = true
    280                         } label: { Text("Set Profile Name") }
    281                         .buttonStyle(.borderedProminent)
    282                     }
    283                 }
    284             }
    285         }
    286         .onChange(of: completed.count) { oldCount, newCount in
    287             if newCount > oldCount {
    288                 completedVisibleCount += (newCount - oldCount)
    289             }
    290         }
    291     }
    292 
    293     // MARK: - List layout (compact width / iPhone)
    294 
    295     @ViewBuilder
    296     private func listLayout(
    297         invites: [InviteEntity],
    298         inProgress: [GameSummary],
    299         revoked: [GameSummary],
    300         completed: [GameSummary],
    301         hasMore: Bool,
    302         usesRoomierType: Bool
    303     ) -> some View {
    304         List {
    305             if !invites.isEmpty {
    306                 Section {
    307                     ForEach(invites, id: \.objectID) { invite in
    308                         inviteRow(for: invite)
    309                     }
    310                 } header: {
    311                     Text("Invited")
    312                 }
    313             }
    314 
    315             if !inProgress.isEmpty {
    316                 Section {
    317                     ForEach(inProgress) { game in
    318                         rowView(for: game, usesRoomierType: usesRoomierType)
    319                     }
    320                 } header: {
    321                     Text("In Progress")
    322                 }
    323             }
    324 
    325             if !revoked.isEmpty {
    326                 Section {
    327                     ForEach(revoked) { game in
    328                         rowView(for: game, usesRoomierType: usesRoomierType)
    329                     }
    330                 } header: {
    331                     Text("Revoked")
    332                 }
    333             }
    334 
    335             if !completed.isEmpty {
    336                 Section {
    337                     ForEach(completed) { game in
    338                         rowView(for: game, usesRoomierType: usesRoomierType)
    339                     }
    340                 } header: {
    341                     Text("Completed")
    342                 } footer: {
    343                     if hasMore {
    344                         loadMoreButton
    345                     }
    346                 }
    347             }
    348         }
    349         .refreshable {
    350             await onRefresh()
    351         }
    352     }
    353 
    354     // MARK: - Grid layout (regular width / iPad)
    355 
    356     private var gridColumns: [GridItem] {
    357         [GridItem(.adaptive(minimum: 320), spacing: 12)]
    358     }
    359 
    360     @ViewBuilder
    361     private func gridLayout(
    362         invites: [InviteEntity],
    363         inProgress: [GameSummary],
    364         revoked: [GameSummary],
    365         completed: [GameSummary],
    366         hasMore: Bool,
    367         usesRoomierType: Bool
    368     ) -> some View {
    369         ScrollView {
    370             LazyVStack(spacing: 8) {
    371                 if !invites.isEmpty {
    372                     Section {
    373                         LazyVGrid(columns: gridColumns, spacing: 12) {
    374                             ForEach(invites, id: \.objectID) { invite in
    375                                 inviteCard(for: invite)
    376                             }
    377                         }
    378                         .padding(.horizontal)
    379                     } header: {
    380                         gridSectionHeader("Invited")
    381                     }
    382                 }
    383 
    384                 if !inProgress.isEmpty {
    385                     Section {
    386                         LazyVGrid(columns: gridColumns, spacing: 12) {
    387                             ForEach(inProgress) { game in
    388                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    389                             }
    390                         }
    391                         .padding(.horizontal)
    392                     } header: {
    393                         gridSectionHeader("In Progress")
    394                     }
    395                 }
    396 
    397                 if !revoked.isEmpty {
    398                     Section {
    399                         LazyVGrid(columns: gridColumns, spacing: 12) {
    400                             ForEach(revoked) { game in
    401                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    402                             }
    403                         }
    404                         .padding(.horizontal)
    405                     } header: {
    406                         gridSectionHeader("Revoked")
    407                     }
    408                 }
    409 
    410                 if !completed.isEmpty {
    411                     Section {
    412                         LazyVGrid(columns: gridColumns, spacing: 12) {
    413                             ForEach(completed) { game in
    414                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    415                             }
    416                         }
    417                         .padding(.horizontal)
    418 
    419                         if hasMore {
    420                             loadMoreButton
    421                                 .padding(.horizontal)
    422                         }
    423                     } header: {
    424                         gridSectionHeader("Completed")
    425                     }
    426                 }
    427             }
    428             .padding(.vertical, 8)
    429         }
    430         .background(Color(.systemGroupedBackground))
    431         .refreshable {
    432             await onRefresh()
    433         }
    434     }
    435 
    436     private func gridSectionHeader(_ title: String) -> some View {
    437         Text(title)
    438             .font(.footnote.weight(.semibold))
    439             .foregroundStyle(.secondary)
    440             .frame(maxWidth: .infinity, alignment: .leading)
    441             .padding(.horizontal, 16)
    442             .padding(.vertical, 8)
    443             .background(Color(.systemGroupedBackground))
    444     }
    445 
    446     private var loadMoreButton: some View {
    447         HStack {
    448             Spacer()
    449             Button {
    450                 withAnimation(.easeInOut(duration: 0.25)) {
    451                     completedVisibleCount += Self.completedPageSize
    452                 }
    453             } label: {
    454                 Text("Load More")
    455                     .font(.subheadline.weight(.semibold))
    456                     .foregroundColor(.secondary)
    457                     .padding(.horizontal, 18)
    458                     .padding(.vertical, 8)
    459                     .background(Color(.tertiarySystemFill), in: Capsule())
    460             }
    461             .buttonStyle(.plain)
    462             .textCase(nil)
    463             Spacer()
    464         }
    465         .padding(.top, 8)
    466     }
    467 
    468     private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View {
    469         GameCardView(
    470             game: game,
    471             shareController: shareController,
    472             usesRoomierType: usesRoomierType,
    473             onResume: { navigationPath.append(game.id) },
    474             onLeave: { leaveTarget = game },
    475             onResign: { resignTarget = game },
    476             onDelete: { deleteTarget = game }
    477         )
    478     }
    479 
    480     /// The puzzle-shape preview for an invite, decoded from the silhouette
    481     /// segment the inviter sent. Open cells render grey (`.filled`) to read as
    482     /// "not yet playable", matching the link-tap placeholder in
    483     /// `JoiningPuzzleView`. Absent or non-square grids get no thumbnail.
    484     @ViewBuilder
    485     private func inviteThumbnail(for invite: InviteEntity) -> some View {
    486         if let segment = invite.gridSilhouette,
    487            let shape = GridSilhouette.decode(segment) {
    488             GridThumbnailView(
    489                 width: shape.side,
    490                 height: shape.side,
    491                 cells: shape.blocks.map { $0 ? .block : .filled }
    492             )
    493         }
    494     }
    495 
    496     @ViewBuilder
    497     private func inviteCard(for invite: InviteEntity) -> some View {
    498         let inviter = invite.resolvedInviterName ?? "A player"
    499         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    500         HStack(spacing: 12) {
    501             inviteThumbnail(for: invite)
    502             VStack(alignment: .leading, spacing: 2) {
    503                 Text(title)
    504                     .font(.subheadline.weight(.semibold))
    505                     .lineLimit(1)
    506                     .truncationMode(.tail)
    507                 Text("Invited by \(inviter)")
    508                     .font(.footnote)
    509                     .foregroundStyle(.secondary)
    510                     .lineLimit(1)
    511             }
    512             Spacer(minLength: 0)
    513             if acceptingInviteID == invite.objectID {
    514                 ProgressView()
    515             } else {
    516                 Button("Accept") { Task { await accept(invite) } }
    517                     .buttonStyle(.borderedProminent)
    518                     .controlSize(.small)
    519             }
    520             inviteMenu(for: invite)
    521         }
    522         .padding(12)
    523         .frame(maxWidth: .infinity)
    524         .frame(height: CardMetrics.height)
    525         .background(
    526             Color(.secondarySystemGroupedBackground),
    527             in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
    528         )
    529     }
    530 
    531     private func inviteMenu(for invite: InviteEntity) -> some View {
    532         Menu {
    533             Button { Task { await decline(invite) } } label: {
    534                 Label("Decline", systemImage: "xmark")
    535             }
    536             Button(role: .destructive) { blockTarget = invite } label: {
    537                 Label("Block", systemImage: "hand.raised")
    538             }
    539         } label: {
    540             Text("More")
    541                 .foregroundStyle(.primary)
    542         }
    543         .buttonStyle(.bordered)
    544         .controlSize(.small)
    545         .tint(.secondary)
    546         .compositingGroup()
    547     }
    548 
    549     @ViewBuilder
    550     private func inviteRow(for invite: InviteEntity) -> some View {
    551         let inviter = invite.resolvedInviterName ?? "A player"
    552         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    553         HStack {
    554             inviteThumbnail(for: invite)
    555             VStack(alignment: .leading, spacing: 2) {
    556                 Text(title).font(.body.weight(.medium))
    557                 Text("Invited by \(inviter)")
    558                     .font(.caption)
    559                     .foregroundStyle(.secondary)
    560             }
    561             Spacer()
    562             if acceptingInviteID == invite.objectID {
    563                 ProgressView()
    564             } else {
    565                 Button("Accept") { Task { await accept(invite) } }
    566                     .buttonStyle(.borderedProminent)
    567                     .controlSize(.small)
    568             }
    569             inviteMenu(for: invite)
    570         }
    571         .swipeActions(edge: .trailing) {
    572             Button("Decline") { Task { await decline(invite) } }
    573                 .tint(.gray)
    574             Button("Block", role: .destructive) { blockTarget = invite }
    575         }
    576     }
    577 
    578     private func accept(_ invite: InviteEntity) async {
    579         guard let acceptInvite,
    580               let url = invite.shareURL,
    581               let ping = invite.pingRecordName
    582         else { return }
    583         acceptingInviteID = invite.objectID
    584         announcements.dismiss(id: Self.inviteErrorID)
    585         defer { acceptingInviteID = nil }
    586         do {
    587             try await acceptInvite(url, ping)
    588         } catch {
    589             announcements.post(Announcement(
    590                 id: Self.inviteErrorID,
    591                 scope: .global,
    592                 severity: .error,
    593                 title: "Accepting Failed",
    594                 body: error.localizedDescription,
    595                 dismissal: .manual
    596             ))
    597         }
    598     }
    599 
    600     /// Single-slot id for the invite-accept failure banner — a fresh
    601     /// failure replaces the prior one rather than stacking.
    602     private static let inviteErrorID = "invite-accept-error"
    603 
    604     /// Single-slot id for game-list destructive-action failures (decline,
    605     /// resign, delete) — a fresh failure replaces the prior one.
    606     private static let destructiveActionErrorID = "game-list-destructive-action-error"
    607 
    608     private func decline(_ invite: InviteEntity) async {
    609         guard let declineInvite, let gameID = invite.gameID else { return }
    610         do {
    611             try await declineInvite(gameID)
    612         } catch {
    613             announcements.post(Announcement(
    614                 id: Self.destructiveActionErrorID,
    615                 scope: .global,
    616                 severity: .error,
    617                 title: "Declining Failed",
    618                 body: error.localizedDescription,
    619                 dismissal: .manual
    620             ))
    621         }
    622     }
    623 
    624     @ViewBuilder
    625     private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View {
    626         GameRowView(
    627             game: game,
    628             shareController: shareController,
    629             usesRoomierType: usesRoomierType,
    630             onResume: { navigationPath.append(game.id) },
    631             onLeave: { leaveTarget = game },
    632             onResign: { resignTarget = game },
    633             onDelete: { deleteTarget = game }
    634         )
    635         .background(
    636             NavigationLink(value: game.id) { EmptyView() }
    637                 .opacity(0)
    638         )
    639         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
    640             if !game.isOwned && game.isShared {
    641                 Button("Leave", role: .destructive) {
    642                     leaveTarget = game
    643                 }
    644             } else {
    645                 Button("Delete", role: .destructive) {
    646                     deleteTarget = game
    647                 }
    648             }
    649         }
    650     }
    651 
    652     private func leaveShare(game: GameSummary) async {
    653         do {
    654             try await shareController.leaveShare(gameID: game.id)
    655             leaveTarget = nil
    656         } catch {
    657             leaveError = error
    658             leaveTarget = nil
    659         }
    660     }
    661 
    662     private func usesRoomierType(for size: CGSize) -> Bool {
    663         size.height >= 760 && dynamicTypeSize <= .medium
    664     }
    665 }
    666 
    667 // MARK: - Row
    668 
    669 private struct GameRowView: View {
    670     let game: GameSummary
    671     let shareController: ShareController
    672     let usesRoomierType: Bool
    673     var onResume: () -> Void = {}
    674     var onLeave: () -> Void = {}
    675     var onResign: () -> Void = {}
    676     var onDelete: () -> Void = {}
    677     @State private var isShowingShareSheet = false
    678 
    679     var body: some View {
    680         let showsUnreadBadge = game.hasUnreadOtherMoves
    681 
    682         HStack(spacing: 12) {
    683             GridThumbnailView(
    684                 width: game.gridWidth,
    685                 height: game.gridHeight,
    686                 cells: game.thumbnailCells
    687             )
    688             .overlay(alignment: .topTrailing) {
    689                 if showsUnreadBadge {
    690                     Circle()
    691                         .fill(.red)
    692                         .frame(width: 14, height: 14)
    693                         .overlay(
    694                             Circle()
    695                                 .stroke(.background, lineWidth: 2)
    696                         )
    697                         .offset(x: 5, y: -5)
    698                         .accessibilityLabel("Unseen changes")
    699                 }
    700             }
    701             VStack(alignment: .leading, spacing: 2) {
    702                 HStack(spacing: 4) {
    703                     Text(game.title)
    704                         .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
    705                         .lineLimit(1)
    706                         .minimumScaleFactor(0.8)
    707                         .truncationMode(.tail)
    708                     if game.isShared {
    709                         Image(systemName: "person.2.fill")
    710                             .font(.caption)
    711                             .foregroundStyle(.secondary)
    712                     }
    713                 }
    714                 GameMetadataView(
    715                     puzzleDate: game.puzzleDate,
    716                     publisher: game.publisher,
    717                     usesRoomierType: usesRoomierType
    718                 )
    719                 if let date = game.updatedAt {
    720                     LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
    721                 }
    722             }
    723             Spacer()
    724             GameOverflowMenu(
    725                 game: game,
    726                 onShare: { isShowingShareSheet = true },
    727                 onResume: onResume,
    728                 onLeave: onLeave,
    729                 onResign: onResign,
    730                 onDelete: onDelete
    731             )
    732         }
    733         .padding(.vertical, 4)
    734         .sheet(isPresented: $isShowingShareSheet) {
    735             GameShareSheet(
    736                 gameID: game.id,
    737                 title: game.title,
    738                 shareController: shareController
    739             )
    740         }
    741     }
    742 }
    743 
    744 // MARK: - Card (regular width)
    745 
    746 private enum CardMetrics {
    747     static let height: CGFloat = 88
    748     static let cornerRadius: CGFloat = 12
    749 }
    750 
    751 /// Tappable card used in the iPad grid layout. The whole card is one
    752 /// `Button` (so the pressed-state highlight covers the full card), and the
    753 /// overflow `Menu` is layered as an `.overlay` rather than nested inside the
    754 /// button — keeping them siblings means tapping the ellipsis opens the menu
    755 /// instead of also firing the navigation action.
    756 private struct GameCardView: View {
    757     let game: GameSummary
    758     let shareController: ShareController
    759     let usesRoomierType: Bool
    760     var onResume: () -> Void = {}
    761     var onLeave: () -> Void = {}
    762     var onResign: () -> Void = {}
    763     var onDelete: () -> Void = {}
    764     @State private var isShowingShareSheet = false
    765 
    766     var body: some View {
    767         let showsUnreadBadge = game.hasUnreadOtherMoves
    768 
    769         Button(action: onResume) {
    770             HStack(spacing: 12) {
    771                 GridThumbnailView(
    772                     width: game.gridWidth,
    773                     height: game.gridHeight,
    774                     cells: game.thumbnailCells
    775                 )
    776                 .overlay(alignment: .topTrailing) {
    777                     if showsUnreadBadge {
    778                         Circle()
    779                             .fill(.red)
    780                             .frame(width: 14, height: 14)
    781                             .overlay(
    782                                 Circle()
    783                                     .stroke(.background, lineWidth: 2)
    784                             )
    785                             .offset(x: 5, y: -5)
    786                             .accessibilityLabel("Unseen changes")
    787                     }
    788                 }
    789                 VStack(alignment: .leading, spacing: 2) {
    790                     HStack(spacing: 4) {
    791                         Text(game.title)
    792                             .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
    793                             .lineLimit(1)
    794                             .minimumScaleFactor(0.8)
    795                             .truncationMode(.tail)
    796                         if game.isShared {
    797                             Image(systemName: "person.2.fill")
    798                                 .font(.caption)
    799                                 .foregroundStyle(.secondary)
    800                         }
    801                     }
    802                     GameMetadataView(
    803                         puzzleDate: game.puzzleDate,
    804                         publisher: game.publisher,
    805                         usesRoomierType: usesRoomierType
    806                     )
    807                     if let date = game.updatedAt {
    808                         LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
    809                     }
    810                 }
    811                 Spacer(minLength: 0)
    812                 // Reserve room for the overflow menu, which is layered as an
    813                 // overlay so its taps don't fall through to this button.
    814                 Color.clear.frame(width: 32, height: 32)
    815             }
    816             .padding(12)
    817             .frame(maxWidth: .infinity)
    818             .frame(height: CardMetrics.height)
    819         }
    820         .buttonStyle(CardButtonStyle())
    821         .overlay(alignment: .trailing) {
    822             GameOverflowMenu(
    823                 game: game,
    824                 onShare: { isShowingShareSheet = true },
    825                 onResume: onResume,
    826                 onLeave: onLeave,
    827                 onResign: onResign,
    828                 onDelete: onDelete
    829             )
    830             .padding(.trailing, 12)
    831         }
    832         .sheet(isPresented: $isShowingShareSheet) {
    833             GameShareSheet(
    834                 gameID: game.id,
    835                 title: game.title,
    836                 shareController: shareController
    837             )
    838         }
    839     }
    840 }
    841 
    842 private struct CardButtonStyle: ButtonStyle {
    843     func makeBody(configuration: Configuration) -> some View {
    844         configuration.label
    845             .background(
    846                 Color(.secondarySystemGroupedBackground),
    847                 in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
    848             )
    849             .overlay {
    850                 if configuration.isPressed {
    851                     RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
    852                         .fill(Color.primary.opacity(0.06))
    853                 }
    854             }
    855             .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius))
    856     }
    857 }
    858 
    859 // MARK: - Shared overflow menu
    860 
    861 private struct GameOverflowMenu: View {
    862     let game: GameSummary
    863     var onShare: () -> Void
    864     var onResume: () -> Void
    865     var onLeave: () -> Void
    866     var onResign: () -> Void
    867     var onDelete: () -> Void
    868 
    869     var body: some View {
    870         Menu {
    871             Button { onShare() } label: {
    872                 Label("Share", systemImage: "square.and.arrow.up")
    873             }
    874             .disabled(!game.isOwned)
    875             Button { onResume() } label: {
    876                 Label("Resume", systemImage: "square.and.pencil")
    877             }
    878             Section {
    879                 Button(role: .destructive) { onResign() } label: {
    880                     Label("Resign", systemImage: "flag")
    881                 }
    882                 if !game.isOwned && game.isShared {
    883                     Button(role: .destructive) { onLeave() } label: {
    884                         Label("Leave", systemImage: "rectangle.portrait.and.arrow.right")
    885                     }
    886                 } else {
    887                     Button(role: .destructive) { onDelete() } label: {
    888                         Label("Delete", systemImage: "trash")
    889                     }
    890                 }
    891             }
    892         } label: {
    893             Image(systemName: "ellipsis")
    894                 .font(.body)
    895                 .frame(width: 32, height: 32)
    896                 .contentShape(Rectangle())
    897         }
    898         .tint(.secondary)
    899         .compositingGroup()
    900     }
    901 }
    902 
    903 private struct LastUpdatedView: View {
    904     let date: Date
    905     let usesRoomierType: Bool
    906 
    907     var body: some View {
    908         TimelineView(.lastUpdated(from: date)) { context in
    909             Text(text(now: context.date))
    910                 .font(usesRoomierType ? .footnote : .caption)
    911                 .foregroundStyle(.secondary)
    912         }
    913     }
    914 
    915     private func text(now: Date) -> String {
    916         let elapsed = max(0, now.timeIntervalSince(date))
    917         if elapsed < 60 {
    918             let seconds = max(1, Int(elapsed.rounded(.down)))
    919             return "Last updated \(seconds) \(seconds == 1 ? "second" : "seconds") ago"
    920         }
    921         if elapsed < 60 * 60 {
    922             let minutes = Int((elapsed / 60).rounded(.down))
    923             return "Last updated \(minutes) \(minutes == 1 ? "minute" : "minutes") ago"
    924         }
    925         if elapsed <= 48 * 60 * 60 {
    926             let hours = Int((elapsed / (60 * 60)).rounded(.down))
    927             return "Last updated \(hours) \(hours == 1 ? "hour" : "hours") ago"
    928         }
    929         return "Last updated on \(date.formatted(.dateTime.day().month(.abbreviated).year()))"
    930     }
    931 }
    932 
    933 private struct LastUpdatedSchedule: TimelineSchedule {
    934     let anchor: Date
    935 
    936     func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> {
    937         var next = startDate
    938         return AnyIterator {
    939             let current = next
    940             let elapsed = max(0, current.timeIntervalSince(anchor))
    941             let step: TimeInterval
    942             if elapsed < 60 {
    943                 step = 1
    944             } else if elapsed < 60 * 60 {
    945                 step = 60
    946             } else if elapsed <= 48 * 60 * 60 {
    947                 step = 60 * 60
    948             } else {
    949                 return nil
    950             }
    951             next = current.addingTimeInterval(step)
    952             return current
    953         }
    954     }
    955 }
    956 
    957 extension TimelineSchedule where Self == LastUpdatedSchedule {
    958     static func lastUpdated(from date: Date) -> LastUpdatedSchedule {
    959         LastUpdatedSchedule(anchor: date)
    960     }
    961 }
    962 
    963 private struct GameMetadataView: View {
    964     let puzzleDate: Date?
    965     let publisher: String?
    966     let usesRoomierType: Bool
    967 
    968     private var font: Font {
    969         usesRoomierType ? .subheadline : .footnote
    970     }
    971 
    972     var body: some View {
    973         if let puzzleDate, let publisher {
    974             ViewThatFits(in: .horizontal) {
    975                 HStack(spacing: 0) {
    976                     Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
    977                     Text(" • ")
    978                     Text(publisher)
    979                 }
    980                 .font(font)
    981                 .lineLimit(1)
    982 
    983                 VStack(alignment: .leading, spacing: 2) {
    984                     puzzleDateView(puzzleDate)
    985                     publisherView(publisher)
    986                 }
    987             }
    988         } else {
    989             if let puzzleDate {
    990                 puzzleDateView(puzzleDate)
    991             }
    992             if let publisher {
    993                 publisherView(publisher)
    994             }
    995         }
    996     }
    997 
    998     private func puzzleDateView(_ puzzleDate: Date) -> some View {
    999         Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
   1000             .font(font)
   1001     }
   1002 
   1003     private func publisherView(_ publisher: String) -> some View {
   1004         Text(publisher)
   1005             .font(font)
   1006             .lineLimit(1)
   1007             .truncationMode(.tail)
   1008     }
   1009 }