crossmate

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

GameListView.swift (37602B)


      1 import CoreData
      2 import SwiftUI
      3 
      4 struct GameListView: View {
      5     let store: GameStore
      6     let shareController: ShareController
      7     let authorIdentity: AuthorIdentity
      8     let onRefresh: () async -> Void
      9     let onAppear: () async -> Void
     10     let onLoadRecentCompleted: (Date) async -> GameArchiver.CompletedPage
     11     let onLoadMoreCompleted: () async -> GameArchiver.CompletedPage
     12     let onDisappear: () -> Void
     13     let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)?
     14     @Binding var pendingInviteNotificationGameID: UUID?
     15     @Binding var navigationPath: NavigationPath
     16 
     17     @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled
     18     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     19     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
     20     @FetchRequest(
     21         sortDescriptors: [],
     22         predicate: GameEntity.visibleInGameListPredicate,
     23         animation: .default
     24     )
     25     private var games: FetchedResults<GameEntity>
     26 
     27     @FetchRequest(
     28         sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)],
     29         predicate: NSPredicate(format: "status == %@", "pending"),
     30         animation: .default
     31     )
     32     private var pendingInvites: FetchedResults<InviteEntity>
     33 
     34     @FetchRequest(
     35         sortDescriptors: [],
     36         predicate: NSPredicate(format: "isBlocked == YES")
     37     )
     38     // Drives the Invited section filter below. Pending invites from blocked
     39     // friends can exist briefly until the ping cleanup path consumes them,
     40     // but the library and badge state should never surface them.
     41     private var blockedFriends: FetchedResults<FriendEntity>
     42 
     43     @Environment(\.appActions) private var appActions
     44     @Environment(PlayerPreferences.self) private var preferences
     45     @Environment(AnnouncementCenter.self) private var announcements
     46     @Environment(EventLog.self) private var eventLog
     47     @Environment(TipStore.self) private var tips
     48     #if DEBUG
     49     @Environment(DriveMonitor.self) private var driveMonitor
     50     #endif
     51     @State private var acceptingInviteID: NSManagedObjectID?
     52     @State private var blockTarget: InviteEntity?
     53 
     54     @State private var newGamePresentation: NewGamePresentation?
     55     @State private var showingSettings = false
     56     @AppStorage(AppServices.showV4NoticeDefaultsKey) private var showV4Notice = false
     57     @State private var showingFriends = false
     58     @State private var queuedNewGameInviteTarget: FriendNewGameTarget?
     59     @State private var deleteTarget: GameSummary?
     60     @State private var resignTarget: GameSummary?
     61     @State private var leaveTarget: GameSummary?
     62     @State private var showingNamePrompt = false
     63     @State private var nameDraft = ""
     64     @State private var summaryCache = GameSummaryCache()
     65     @State private var completedCutoff = Calendar.current.date(
     66         byAdding: .day,
     67         value: -7,
     68         to: Date()
     69     ) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
     70     @State private var cloudHasMoreCompleted = false
     71     @State private var isLoadingMoreCompleted = false
     72     /// Shows the "Never show me tips" opt-out in the banner slot for a few
     73     /// seconds after a tip is dismissed; cleared by the timer or by tapping it.
     74     @State private var showTipOptOut = false
     75     @State private var tipOptOutHideTask: Task<Void, Never>?
     76     @State private var containerHeight: CGFloat = 0
     77     @AccessibilityFocusState private var isPuzzleListHeadingFocused: Bool
     78 
     79     private struct NewGamePresentation: Identifiable {
     80         let id = UUID()
     81         let inviteTarget: FriendNewGameTarget?
     82     }
     83 
     84     var body: some View {
     85         VStack(spacing: 0) {
     86             accessibilityHeading
     87             if let announcement = announcements.currentGlobal() {
     88                 AnnouncementBanner(announcement: announcement, contentSize: .prominent) {
     89                     dismissAnnouncement(announcement)
     90                 }
     91                 .padding(.horizontal)
     92                 .padding(.top, 8)
     93                 .transition(.move(edge: .top).combined(with: .opacity))
     94             } else if showTipOptOut {
     95                 Button("Never Show Tips") {
     96                     tipOptOutHideTask?.cancel()
     97                     tips.disable()
     98                     showTipOptOut = false
     99                 }
    100                 .buttonStyle(.borderedProminent)
    101                 .buttonBorderShape(.capsule)
    102                 .controlSize(.small)
    103                 .padding(.top, 8)
    104                 .transition(.opacity)
    105             }
    106             content(usesRoomierType: usesRoomierType)
    107         }
    108         .background(Color(.systemGroupedBackground))
    109         .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal())
    110         .animation(.easeInOut(duration: 0.3), value: showTipOptOut)
    111         .onGeometryChange(for: CGFloat.self) { proxy in
    112             proxy.size.height
    113         } action: { height in
    114             containerHeight = height
    115         }
    116         .navigationTitle("")
    117         .navigationBarTitleDisplayMode(.inline)
    118         .toolbar {
    119             ToolbarItem(placement: .topBarLeading) {
    120                 Button {
    121                     showingSettings = true
    122                 } label: {
    123                     Image(systemName: "gearshape")
    124                 }
    125                 .accessibilityLabel("Settings")
    126             }
    127             ToolbarItem(placement: .topBarTrailing) {
    128                 Button {
    129                     showingFriends = true
    130                 } label: {
    131                     Image(systemName: "person.2")
    132                 }
    133                 .accessibilityLabel("Crossmates")
    134             }
    135             if #available(iOS 26.0, *) {
    136                 ToolbarSpacer(.fixed, placement: .topBarTrailing)
    137             }
    138             ToolbarItem(placement: .topBarTrailing) {
    139                 Button {
    140                     newGamePresentation = NewGamePresentation(inviteTarget: nil)
    141                 } label: {
    142                     Image(systemName: "plus")
    143                 }
    144                 .accessibilityLabel("New Puzzle")
    145             }
    146         }
    147         .sheet(isPresented: $showingSettings) {
    148             SettingsView()
    149         }
    150         .sheet(isPresented: $showV4Notice) {
    151             NoticeView { showV4Notice = false }
    152         }
    153         .sheet(isPresented: $showingFriends, onDismiss: presentQueuedFriendNewGame) {
    154             FriendsView { target in
    155                 queuedNewGameInviteTarget = target
    156             }
    157         }
    158         .sheet(item: $newGamePresentation) { presentation in
    159             NewGameSheet(
    160                 store: store,
    161                 inviteTargetName: presentation.inviteTarget?.displayName
    162             ) { gameID in
    163                 navigationPath.append(gameID)
    164                 if let target = presentation.inviteTarget {
    165                     Task { await inviteNewGame(gameID, to: target) }
    166                 }
    167             }
    168         }
    169         .task {
    170             await onAppear()
    171             let page = await onLoadRecentCompleted(completedCutoff)
    172             cloudHasMoreCompleted = page.hasMore
    173             reconcilePendingInviteNotification()
    174             focusPuzzleListHeading()
    175         }
    176         .onReceive(NotificationCenter.default.publisher(for: .chronicleZoneDidChange)) { _ in
    177             Task {
    178                 let page = await onLoadRecentCompleted(completedCutoff)
    179                 cloudHasMoreCompleted = page.hasMore
    180             }
    181         }
    182         #if DEBUG
    183         .task {
    184             // Marketing "import" scene: render the real New Puzzle sheet over the
    185             // seeded library instead of a mock. Seed the Imported tab with fake
    186             // files, preselect it, then present the real sheet.
    187             guard MarketingLaunch.isImportScene else { return }
    188             driveMonitor.seedMarketingImports()
    189             UserDefaults.standard.set(PuzzleSource.imported.rawValue, forKey: "lastPuzzleSource")
    190             newGamePresentation = NewGamePresentation(inviteTarget: nil)
    191         }
    192         #endif
    193         .onChange(of: pendingInviteNotificationGameID) { _, _ in
    194             reconcilePendingInviteNotification()
    195         }
    196         .onChange(of: pendingInvites.count) { _, _ in
    197             reconcilePendingInviteNotification()
    198         }
    199         .onChange(of: isVoiceOverEnabled) { _, enabled in
    200             if enabled {
    201                 focusPuzzleListHeading()
    202             }
    203         }
    204         .onDisappear {
    205             onDisappear()
    206         }
    207         .alert("Resign Puzzle?", isPresented: .init(
    208             get: { resignTarget != nil },
    209             set: { if !$0 { resignTarget = nil } }
    210         )) {
    211             Button("Resign", role: .destructive) {
    212                 if let target = resignTarget {
    213                     do {
    214                         try store.resignGame(id: target.id)
    215                     } catch {
    216                         announcements.post(Announcement(
    217                             id: Self.destructiveActionErrorID,
    218                             scope: .global,
    219                             severity: .error,
    220                             title: "Resigning Failed",
    221                             body: error.localizedDescription,
    222                             dismissal: .manual
    223                         ))
    224                     }
    225                 }
    226             }
    227             Button("Cancel", role: .cancel) {}
    228         } message: {
    229             if let target = resignTarget {
    230                 Text("This will reveal all answers for \"\(target.title)\".")
    231             }
    232         }
    233         .alert("Leave Puzzle?", isPresented: .init(
    234             get: { leaveTarget != nil },
    235             set: { if !$0 { leaveTarget = nil } }
    236         )) {
    237             Button("Leave", role: .destructive) {
    238                 if let target = leaveTarget {
    239                     Task { await leaveShare(game: target) }
    240                 }
    241             }
    242             Button("Cancel", role: .cancel) {}
    243         } message: {
    244             if let target = leaveTarget {
    245                 Text("You will lose access to \"\(target.title)\".")
    246             }
    247         }
    248         .alert("Delete Puzzle?", isPresented: .init(
    249             get: { deleteTarget != nil },
    250             set: { if !$0 { deleteTarget = nil } }
    251         )) {
    252             Button("Delete", role: .destructive) {
    253                 if let target = deleteTarget {
    254                     do {
    255                         try store.deleteGame(id: target.id)
    256                     } catch {
    257                         announcements.post(Announcement(
    258                             id: Self.destructiveActionErrorID,
    259                             scope: .global,
    260                             severity: .error,
    261                             title: "Deleting Failed",
    262                             body: error.localizedDescription,
    263                             dismissal: .manual
    264                         ))
    265                     }
    266                 }
    267             }
    268             Button("Cancel", role: .cancel) {}
    269         } message: {
    270             if let target = deleteTarget {
    271                 if target.isOwned && target.isShared {
    272                     Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.")
    273                 } else {
    274                     Text("This will permanently delete \"\(target.title)\" and all progress.")
    275                 }
    276             }
    277         }
    278         .alert("Block This Player?", isPresented: .init(
    279             get: { blockTarget != nil },
    280             set: { if !$0 { blockTarget = nil } }
    281         )) {
    282             Button("Block", role: .destructive) {
    283                 if let target = blockTarget, let authorID = target.inviterAuthorID {
    284                     Task { await appActions?.blockFriend(authorID: authorID) }
    285                 }
    286             }
    287             Button("Cancel", role: .cancel) {}
    288         } message: {
    289             let name = blockTarget?.resolvedInviterName ?? "this player"
    290             Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be hidden from your list. You can unblock them later to bring everything back.")
    291         }
    292         .alert("Set Profile Name", isPresented: $showingNamePrompt) {
    293             TextField("Name", text: $nameDraft)
    294                 .textInputAutocapitalization(.never)
    295                 .autocorrectionDisabled()
    296             Button("Cancel", role: .cancel) {}
    297             Button("Save") {
    298                 let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
    299                 if !trimmed.isEmpty {
    300                     preferences.name = trimmed
    301                     nameDraft = trimmed
    302                 }
    303             }
    304             .keyboardShortcut(.defaultAction)
    305         } message: {
    306             Text("Enter the name other players will see.")
    307         }
    308     }
    309 
    310     private var accessibilityHeading: some View {
    311         Text("List of Puzzles")
    312             .accessibilityAddTraits(.isHeader)
    313             .accessibilityFocused($isPuzzleListHeadingFocused)
    314             .frame(width: 1, height: 1)
    315             .opacity(0.01)
    316             .allowsHitTesting(false)
    317     }
    318 
    319     private func focusPuzzleListHeading() {
    320         guard isVoiceOverEnabled else { return }
    321         isPuzzleListHeadingFocused = true
    322     }
    323 
    324     /// Dismisses the banner's announcement. For a tip, also records it as
    325     /// dismissed so it never returns, and surfaces the "Never show me tips"
    326     /// opt-out in its place for a few seconds.
    327     private func dismissAnnouncement(_ announcement: Announcement) {
    328         announcements.dismiss(id: announcement.id)
    329         guard let tipID = Tip.tipID(fromAnnouncementID: announcement.id) else { return }
    330         tips.markDismissed(tipID)
    331         showTipOptOut = true
    332         tipOptOutHideTask?.cancel()
    333         tipOptOutHideTask = Task { @MainActor in
    334             try? await Task.sleep(for: .seconds(6))
    335             guard !Task.isCancelled else { return }
    336             showTipOptOut = false
    337         }
    338     }
    339 
    340     private func reconcilePendingInviteNotification() {
    341         guard let gameID = pendingInviteNotificationGameID else { return }
    342         if pendingInvites.contains(where: { $0.gameID == gameID }) {
    343             announcements.dismiss(id: Self.inviteSyncID)
    344             pendingInviteNotificationGameID = nil
    345             return
    346         }
    347         announcements.post(Announcement(
    348             id: Self.inviteSyncID,
    349             scope: .global,
    350             severity: .info,
    351             title: "Syncing Invitation",
    352             body: "An invitation is still syncing. It should appear shortly.",
    353             dismissal: .transient(after: 8)
    354         ))
    355     }
    356 
    357     private func presentQueuedFriendNewGame() {
    358         guard let target = queuedNewGameInviteTarget else { return }
    359         queuedNewGameInviteTarget = nil
    360         newGamePresentation = NewGamePresentation(inviteTarget: target)
    361     }
    362 
    363     private func inviteNewGame(_ gameID: UUID, to target: FriendNewGameTarget) async {
    364         guard let appActions else { return }
    365         announcements.dismiss(id: Self.newGameInviteErrorID)
    366         do {
    367             try await appActions.inviteFriend(gameID: gameID, friendAuthorID: target.authorID)
    368         } catch SyncEngine.PingOutboxError.deliveryPending {
    369             // The invite is durably queued; CloudKit just hasn't confirmed yet.
    370             // Don't raise a failure banner — the async delivery update surfaces
    371             // one only if the send is ultimately rejected.
    372             eventLog.note(
    373                 "new game friend invite queued game=\(gameID.uuidString) friend=\(target.authorID)",
    374                 level: "info"
    375             )
    376         } catch {
    377             announcements.dismiss(
    378                 id: InviteDeliveryStore.failureAnnouncementID(
    379                     gameID: gameID,
    380                     friendAuthorID: target.authorID
    381                 )
    382             )
    383             let quotaExceeded = (error as? SyncEngine.PingOutboxError)?
    384                 .isQuotaExceeded == true
    385             eventLog.note(
    386                 "new game friend invite failed game=\(gameID.uuidString) friend=\(target.authorID): \(error)",
    387                 level: "error"
    388             )
    389             announcements.post(Announcement(
    390                 id: Self.newGameInviteErrorID,
    391                 scope: .game(gameID),
    392                 severity: .error,
    393                 title: quotaExceeded
    394                     ? String(localized: InviteDeliveryFailure.quotaExceeded.title)
    395                     : "Inviting Failed",
    396                 body: quotaExceeded
    397                     ? String(localized: InviteDeliveryFailure.quotaExceeded.body)
    398                     : "\(target.displayName) could not be invited. Try again from the Share menu.",
    399                 dismissal: .manual
    400             ))
    401         }
    402     }
    403 
    404     @ViewBuilder
    405     private func content(usesRoomierType: Bool) -> some View {
    406         let summaries = games.compactMap {
    407             summaryCache.summary(
    408                 for: $0,
    409                 localAuthorID: authorIdentity.currentID,
    410                 localName: preferences.name,
    411                 localColor: preferences.color
    412             )
    413         }
    414         let inProgress = summaries
    415             .filter { $0.completedAt == nil && !$0.isAccessRevoked }
    416             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    417         let revoked = summaries
    418             .filter { $0.isAccessRevoked }
    419             .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
    420         let completed = summaries
    421             .filter { $0.completedAt != nil && !$0.isAccessRevoked }
    422             .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
    423         let visibleCompleted = completed.filter {
    424             ($0.completedAt ?? .distantPast) >= completedCutoff
    425         }
    426         let olderLocal = completed.filter {
    427             ($0.completedAt ?? .distantPast) < completedCutoff
    428         }
    429         let nextLocalCutoff = olderLocal
    430             .prefix(GameArchiver.completedPageSize)
    431             .last?
    432             .completedAt
    433         let hasMore = cloudHasMoreCompleted || !olderLocal.isEmpty
    434 
    435         let blockedIDs = Set(blockedFriends.compactMap { $0.authorID })
    436         let visibleInvites = pendingInvites.filter {
    437             guard let inviter = $0.inviterAuthorID else { return true }
    438             return !blockedIDs.contains(inviter)
    439         }
    440 
    441         Group {
    442             if horizontalSizeClass == .regular {
    443                 gridLayout(
    444                     invites: visibleInvites,
    445                     inProgress: inProgress,
    446                     revoked: revoked,
    447                     completed: visibleCompleted,
    448                     hasMore: hasMore,
    449                     nextLocalCutoff: nextLocalCutoff,
    450                     usesRoomierType: usesRoomierType
    451                 )
    452             } else {
    453                 listLayout(
    454                     invites: visibleInvites,
    455                     inProgress: inProgress,
    456                     revoked: revoked,
    457                     completed: visibleCompleted,
    458                     hasMore: hasMore,
    459                     nextLocalCutoff: nextLocalCutoff,
    460                     usesRoomierType: usesRoomierType
    461                 )
    462             }
    463         }
    464         .overlay {
    465             if games.isEmpty {
    466                 Group {
    467                     if preferences.hasName {
    468                         ContentUnavailableView {
    469                             Label("No Puzzles", systemImage: "square.grid.3x3")
    470                         } description: {
    471                             Text("Tap the + button to start a new puzzle, or pull down to refresh.")
    472                         }
    473                     } else {
    474                         ContentUnavailableView {
    475                             Label("Set Your Profile Name", systemImage: "person.text.rectangle")
    476                         } description: {
    477                             Text("Choose the name other players will see.")
    478                         } actions: {
    479                             Button {
    480                                 nameDraft = ""
    481                                 showingNamePrompt = true
    482                             } label: { Text("Set Profile Name") }
    483                             .buttonStyle(.borderedProminent)
    484                         }
    485                     }
    486                 }
    487                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    488                 .background(Color(.systemGroupedBackground))
    489             }
    490         }
    491     }
    492 
    493     // MARK: - List layout (compact width / iPhone)
    494 
    495     @ViewBuilder
    496     private func listLayout(
    497         invites: [InviteEntity],
    498         inProgress: [GameSummary],
    499         revoked: [GameSummary],
    500         completed: [GameSummary],
    501         hasMore: Bool,
    502         nextLocalCutoff: Date?,
    503         usesRoomierType: Bool
    504     ) -> some View {
    505         List {
    506             if !invites.isEmpty {
    507                 Section {
    508                     ForEach(invites, id: \.objectID) { invite in
    509                         inviteRow(for: invite)
    510                     }
    511                 } header: {
    512                     listSectionHeader("Invited")
    513                 }
    514             }
    515 
    516             if !inProgress.isEmpty {
    517                 Section {
    518                     ForEach(inProgress) { game in
    519                         rowView(for: game, usesRoomierType: usesRoomierType)
    520                     }
    521                 } header: {
    522                     listSectionHeader("In Progress")
    523                 }
    524             }
    525 
    526             if !revoked.isEmpty {
    527                 Section {
    528                     ForEach(revoked) { game in
    529                         rowView(for: game, usesRoomierType: usesRoomierType)
    530                     }
    531                 } header: {
    532                     listSectionHeader("Revoked")
    533                 }
    534             }
    535 
    536             if !completed.isEmpty || hasMore {
    537                 Section {
    538                     ForEach(completed, id: \.listID) { game in
    539                         rowView(for: game, usesRoomierType: usesRoomierType)
    540                     }
    541                 } header: {
    542                     listSectionHeader("Completed")
    543                 } footer: {
    544                     if hasMore {
    545                         loadMoreButton(nextLocalCutoff: nextLocalCutoff)
    546                     }
    547                 }
    548             }
    549         }
    550         .refreshable {
    551             await refreshList()
    552         }
    553     }
    554 
    555     private func listSectionHeader(_ title: String) -> some View {
    556         Text(title)
    557             .accessibilityAddTraits(.isHeader)
    558     }
    559 
    560     // MARK: - Grid layout (regular width / iPad)
    561 
    562     private var gridColumns: [GridItem] {
    563         // 380 keeps the 13" iPad to two portrait columns (three at 320 left
    564         // each card's text column so narrow that every headline title was
    565         // scale-crushed back down to metadata size).
    566         [GridItem(.adaptive(minimum: 380), spacing: 12)]
    567     }
    568 
    569     @ViewBuilder
    570     private func gridLayout(
    571         invites: [InviteEntity],
    572         inProgress: [GameSummary],
    573         revoked: [GameSummary],
    574         completed: [GameSummary],
    575         hasMore: Bool,
    576         nextLocalCutoff: Date?,
    577         usesRoomierType: Bool
    578     ) -> some View {
    579         ScrollView {
    580             LazyVStack(spacing: 8) {
    581                 if !invites.isEmpty {
    582                     Section {
    583                         LazyVGrid(columns: gridColumns, spacing: 12) {
    584                             ForEach(invites, id: \.objectID) { invite in
    585                                 inviteCard(for: invite)
    586                             }
    587                         }
    588                         .padding(.horizontal)
    589                     } header: {
    590                         gridSectionHeader("Invited")
    591                     }
    592                 }
    593 
    594                 if !inProgress.isEmpty {
    595                     Section {
    596                         LazyVGrid(columns: gridColumns, spacing: 12) {
    597                             ForEach(inProgress) { game in
    598                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    599                             }
    600                         }
    601                         .padding(.horizontal)
    602                     } header: {
    603                         gridSectionHeader("In Progress")
    604                     }
    605                 }
    606 
    607                 if !revoked.isEmpty {
    608                     Section {
    609                         LazyVGrid(columns: gridColumns, spacing: 12) {
    610                             ForEach(revoked) { game in
    611                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    612                             }
    613                         }
    614                         .padding(.horizontal)
    615                     } header: {
    616                         gridSectionHeader("Revoked")
    617                     }
    618                 }
    619 
    620                 if !completed.isEmpty || hasMore {
    621                     Section {
    622                         LazyVGrid(columns: gridColumns, spacing: 12) {
    623                             ForEach(completed, id: \.listID) { game in
    624                                 gameCard(for: game, usesRoomierType: usesRoomierType)
    625                             }
    626                         }
    627                         .padding(.horizontal)
    628 
    629                         if hasMore {
    630                             loadMoreButton(nextLocalCutoff: nextLocalCutoff)
    631                                 .padding(.horizontal)
    632                         }
    633                     } header: {
    634                         gridSectionHeader("Completed")
    635                     }
    636                 }
    637             }
    638             .padding(.vertical, 8)
    639         }
    640         .background(Color(.systemGroupedBackground))
    641         .refreshable {
    642             await refreshList()
    643         }
    644     }
    645 
    646     private func refreshList() async {
    647         await onRefresh()
    648         let page = await onLoadRecentCompleted(completedCutoff)
    649         cloudHasMoreCompleted = page.hasMore
    650     }
    651 
    652     private func gridSectionHeader(_ title: String) -> some View {
    653         Text(title)
    654             .font(.footnote.weight(.semibold))
    655             .foregroundStyle(.secondary)
    656             .frame(maxWidth: .infinity, alignment: .leading)
    657             .padding(.horizontal, 16)
    658             .padding(.vertical, 8)
    659             .background(Color(.systemGroupedBackground))
    660             .accessibilityAddTraits(.isHeader)
    661     }
    662 
    663     private func loadMoreButton(nextLocalCutoff: Date?) -> some View {
    664         HStack {
    665             Spacer()
    666             Button {
    667                 Task {
    668                     isLoadingMoreCompleted = true
    669                     let page = await onLoadMoreCompleted()
    670                     let cutoffs = [nextLocalCutoff, page.oldestCompletedAt].compactMap { $0 }
    671                     if let oldest = cutoffs.min() {
    672                         withAnimation(.easeInOut(duration: 0.25)) {
    673                             completedCutoff = oldest
    674                         }
    675                     }
    676                     cloudHasMoreCompleted = page.hasMore
    677                     isLoadingMoreCompleted = false
    678                 }
    679             } label: {
    680                 if isLoadingMoreCompleted {
    681                     ProgressView()
    682                         .controlSize(.small)
    683                         .padding(.horizontal, 18)
    684                         .padding(.vertical, 8)
    685                 } else {
    686                     Text("Load More")
    687                     .font(.subheadline.weight(.semibold))
    688                     .foregroundColor(.secondary)
    689                     .padding(.horizontal, 18)
    690                     .padding(.vertical, 8)
    691                     .background(Color(.tertiarySystemFill), in: Capsule())
    692                 }
    693             }
    694             .disabled(isLoadingMoreCompleted)
    695             .buttonStyle(.plain)
    696             .textCase(nil)
    697             Spacer()
    698         }
    699         .padding(.top, 8)
    700     }
    701 
    702     private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View {
    703         GameCardView(
    704             game: game,
    705             shareController: shareController,
    706             usesRoomierType: usesRoomierType,
    707             onResume: { navigationPath.append(game.id) },
    708             onLeave: { leaveTarget = game },
    709             onResign: { resignTarget = game },
    710             onDelete: { deleteTarget = game }
    711         )
    712     }
    713 
    714     /// The puzzle-shape preview for an invite, decoded from the silhouette
    715     /// segment the inviter sent. Open cells render grey (`.filled`) to read as
    716     /// "not yet playable", matching the link-tap placeholder in
    717     /// `JoiningPuzzleView`. An absent or undecodable grid gets no thumbnail.
    718     @ViewBuilder
    719     private func inviteThumbnail(for invite: InviteEntity) -> some View {
    720         if let segment = invite.gridSilhouette,
    721            let shape = GridSilhouette.decode(segment) {
    722             GridThumbnailView(
    723                 width: shape.width,
    724                 height: shape.height,
    725                 cells: shape.blocks.map { $0 ? .block : .filled }
    726             )
    727         }
    728     }
    729 
    730     @ViewBuilder
    731     private func inviteCard(for invite: InviteEntity) -> some View {
    732         let inviter = invite.resolvedInviterName ?? "A player"
    733         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    734         HStack(spacing: 12) {
    735             inviteThumbnail(for: invite)
    736             VStack(alignment: .leading, spacing: 2) {
    737                 Text(title)
    738                     .font(.headline)
    739                     .lineLimit(1)
    740                     .truncationMode(.tail)
    741                 Text("Invited by \(inviter)")
    742                     .font(.footnote)
    743                     .foregroundStyle(.secondary)
    744                     .lineLimit(1)
    745             }
    746             Spacer(minLength: 0)
    747             if acceptingInviteID == invite.objectID {
    748                 ProgressView()
    749             } else {
    750                 Button("Accept") { Task { await accept(invite) } }
    751                     .buttonStyle(.borderedProminent)
    752                     .controlSize(.small)
    753             }
    754             inviteMenu(for: invite)
    755         }
    756         .padding(12)
    757         .frame(maxWidth: .infinity)
    758         .frame(height: CardMetrics.height)
    759         .background(
    760             Color(.secondarySystemGroupedBackground),
    761             in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
    762         )
    763         .modifier(InviteAccessibility(
    764             invite: invite,
    765             isAccepting: acceptingInviteID == invite.objectID,
    766             onAccept: { Task { await accept(invite) } },
    767             onDecline: { Task { await decline(invite) } },
    768             onBlock: { blockTarget = invite }
    769         ))
    770     }
    771 
    772     private func inviteMenu(for invite: InviteEntity) -> some View {
    773         Menu {
    774             Button { Task { await decline(invite) } } label: {
    775                 Label("Decline", systemImage: "xmark")
    776             }
    777             Button(role: .destructive) { blockTarget = invite } label: {
    778                 Label("Block", systemImage: "hand.raised")
    779             }
    780         } label: {
    781             Text("More")
    782                 .foregroundStyle(.primary)
    783         }
    784         .buttonStyle(.bordered)
    785         .controlSize(.small)
    786         .tint(.secondary)
    787         .compositingGroup()
    788     }
    789 
    790     @ViewBuilder
    791     private func inviteRow(for invite: InviteEntity) -> some View {
    792         let inviter = invite.resolvedInviterName ?? "A player"
    793         let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    794         HStack {
    795             inviteThumbnail(for: invite)
    796             VStack(alignment: .leading, spacing: 2) {
    797                 Text(title).font(.body.weight(.medium))
    798                 Text("Invited by \(inviter)")
    799                     .font(.caption)
    800                     .foregroundStyle(.secondary)
    801             }
    802             Spacer()
    803             if acceptingInviteID == invite.objectID {
    804                 ProgressView()
    805             } else {
    806                 Button("Accept") { Task { await accept(invite) } }
    807                     .buttonStyle(.borderedProminent)
    808                     .controlSize(.small)
    809             }
    810             inviteMenu(for: invite)
    811         }
    812         .swipeActions(edge: .trailing) {
    813             Button("Decline") { Task { await decline(invite) } }
    814                 .tint(.gray)
    815             Button("Block", role: .destructive) { blockTarget = invite }
    816         }
    817         .modifier(InviteAccessibility(
    818             invite: invite,
    819             isAccepting: acceptingInviteID == invite.objectID,
    820             onAccept: { Task { await accept(invite) } },
    821             onDecline: { Task { await decline(invite) } },
    822             onBlock: { blockTarget = invite }
    823         ))
    824     }
    825 
    826     private struct InviteAccessibility: ViewModifier {
    827         let invite: InviteEntity
    828         let isAccepting: Bool
    829         let onAccept: () -> Void
    830         let onDecline: () -> Void
    831         let onBlock: () -> Void
    832 
    833         func body(content: Content) -> some View {
    834             content
    835                 .accessibilityElement(children: .ignore)
    836                 .accessibilityLabel(accessibilityLabel)
    837                 .accessibilityHint(isAccepting ? "Invitation is being accepted" : "Accepts invitation")
    838                 .accessibilityAddTraits(.isButton)
    839                 .accessibilityAction {
    840                     guard !isAccepting else { return }
    841                     onAccept()
    842                 }
    843                 .accessibilityActions {
    844                     if !isAccepting {
    845                         Button("Accept", action: onAccept)
    846                         Button("Decline", action: onDecline)
    847                         Button("Block", role: .destructive, action: onBlock)
    848                     }
    849                 }
    850         }
    851 
    852         private var accessibilityLabel: String {
    853             let inviter = invite.resolvedInviterName ?? "A player"
    854             let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle"
    855             if isAccepting {
    856                 return "\(title), invited by \(inviter), accepting"
    857             }
    858             return "\(title), invited by \(inviter)"
    859         }
    860     }
    861 
    862     private func accept(_ invite: InviteEntity) async {
    863         guard let url = invite.shareURL,
    864               let ping = invite.pingRecordName
    865         else { return }
    866         let shape = invite.gridSilhouette.flatMap(GridSilhouette.decode)
    867         acceptingInviteID = invite.objectID
    868         announcements.dismiss(id: Self.inviteErrorID)
    869         defer { acceptingInviteID = nil }
    870         do {
    871             if let onAcceptInvite {
    872                 try await onAcceptInvite(url, ping, shape)
    873             } else if let appActions {
    874                 try await appActions.acceptInvite(shareURL: url, pingRecordName: ping)
    875             }
    876         } catch {
    877             let copy = CloudFailureCopy.joinFailure(for: error)
    878             announcements.post(Announcement(
    879                 id: Self.inviteErrorID,
    880                 scope: .global,
    881                 severity: .error,
    882                 title: copy.title,
    883                 body: copy.body,
    884                 dismissal: .manual
    885             ))
    886         }
    887     }
    888 
    889     /// Single-slot id for the invite-accept failure banner — a fresh
    890     /// failure replaces the prior one rather than stacking.
    891     private static let inviteErrorID = "invite-accept-error"
    892     private static let inviteSyncID = "invite-sync-in-progress"
    893 
    894     /// Single-slot id for failures after starting a puzzle from the friends
    895     /// sheet — scoped to the new puzzle, where the user can retry from its
    896     /// Share menu.
    897     private static let newGameInviteErrorID = "new-game-friend-invite-error"
    898 
    899     /// Single-slot id for game-list destructive-action failures (decline,
    900     /// resign, delete, leave) — a fresh failure replaces the prior one.
    901     private static let destructiveActionErrorID = "game-list-destructive-action-error"
    902 
    903     private func decline(_ invite: InviteEntity) async {
    904         guard let appActions, let gameID = invite.gameID else { return }
    905         do {
    906             try await appActions.declineInvite(gameID: gameID)
    907         } catch {
    908             announcements.post(Announcement(
    909                 id: Self.destructiveActionErrorID,
    910                 scope: .global,
    911                 severity: .error,
    912                 title: "Declining Failed",
    913                 body: error.localizedDescription,
    914                 dismissal: .manual
    915             ))
    916         }
    917     }
    918 
    919     @ViewBuilder
    920     private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View {
    921         GameRowView(
    922             game: game,
    923             shareController: shareController,
    924             usesRoomierType: usesRoomierType,
    925             onResume: { navigationPath.append(game.id) },
    926             onLeave: { leaveTarget = game },
    927             onResign: { resignTarget = game },
    928             onDelete: { deleteTarget = game }
    929         )
    930         .background(
    931             NavigationLink(value: game.id) { EmptyView() }
    932                 .opacity(0)
    933         )
    934         .swipeActions(edge: .trailing, allowsFullSwipe: false) {
    935             if game.completedAt == nil && !game.isOwned && game.isShared {
    936                 Button("Leave", role: .destructive) {
    937                     leaveTarget = game
    938                 }
    939             } else {
    940                 Button("Delete", role: .destructive) {
    941                     deleteTarget = game
    942                 }
    943             }
    944         }
    945     }
    946 
    947     private func leaveShare(game: GameSummary) async {
    948         defer { leaveTarget = nil }
    949         do {
    950             try await shareController.leaveShare(gameID: game.id)
    951         } catch {
    952             announcements.post(Announcement(
    953                 id: Self.destructiveActionErrorID,
    954                 scope: .global,
    955                 severity: .error,
    956                 title: "Leaving Failed",
    957                 body: error.localizedDescription,
    958                 dismissal: .manual
    959             ))
    960         }
    961     }
    962 
    963     private var usesRoomierType: Bool {
    964         containerHeight >= 760 && dynamicTypeSize <= .medium
    965     }
    966 }