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 }