GameListView.swift (26570B)
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 onDisappear: () -> Void 11 let onAcceptInvite: ((String, String, GridSilhouette.Grid?) async throws -> Void)? 12 @Binding var navigationPath: NavigationPath 13 14 @Environment(\.managedObjectContext) private var viewContext 15 @Environment(\.dynamicTypeSize) private var dynamicTypeSize 16 @Environment(\.horizontalSizeClass) private var horizontalSizeClass 17 @FetchRequest( 18 sortDescriptors: [], 19 animation: .default 20 ) 21 private var games: FetchedResults<GameEntity> 22 23 @FetchRequest( 24 sortDescriptors: [NSSortDescriptor(keyPath: \InviteEntity.createdAt, ascending: true)], 25 predicate: NSPredicate(format: "status == %@", "pending"), 26 animation: .default 27 ) 28 private var pendingInvites: FetchedResults<InviteEntity> 29 30 @FetchRequest( 31 sortDescriptors: [], 32 predicate: NSPredicate(format: "isBlocked == YES") 33 ) 34 private var blockedFriends: FetchedResults<FriendEntity> 35 36 @Environment(\.acceptInvite) private var acceptInvite 37 @Environment(\.declineInvite) private var declineInvite 38 @Environment(\.blockFriend) private var blockFriend 39 @Environment(\.sendResignPings) private var sendResignPings 40 @Environment(PlayerPreferences.self) private var preferences 41 @Environment(AnnouncementCenter.self) private var announcements 42 @Environment(TipStore.self) private var tips 43 @State private var acceptingInviteID: NSManagedObjectID? 44 @State private var blockTarget: InviteEntity? 45 46 @State private var showingNewGame = false 47 @State private var showingSettings = false 48 @State private var showingFriends = false 49 @State private var deleteTarget: GameSummary? 50 @State private var resignTarget: GameSummary? 51 @State private var leaveTarget: GameSummary? 52 @State private var leaveError: Error? 53 @State private var showingNamePrompt = false 54 @State private var nameDraft = "" 55 @State private var summaryCache = GameSummaryCache() 56 @State private var completedVisibleCount = completedPageSize 57 /// Shows the "Never show me tips" opt-out in the banner slot for a few 58 /// seconds after a tip is dismissed; cleared by the timer or by tapping it. 59 @State private var showTipOptOut = false 60 @State private var tipOptOutHideTask: Task<Void, Never>? 61 62 private static let completedPageSize = 7 63 64 var body: some View { 65 GeometryReader { geometry in 66 VStack(spacing: 0) { 67 if let announcement = announcements.currentGlobal() { 68 AnnouncementBanner(announcement: announcement) { 69 dismissAnnouncement(announcement) 70 } 71 .padding(.horizontal) 72 .padding(.top, 8) 73 .transition(.move(edge: .top).combined(with: .opacity)) 74 } else if showTipOptOut { 75 Button("Never Show Tips") { 76 tipOptOutHideTask?.cancel() 77 tips.disable() 78 showTipOptOut = false 79 } 80 .buttonStyle(.borderedProminent) 81 .buttonBorderShape(.capsule) 82 .controlSize(.small) 83 .padding(.top, 8) 84 .transition(.opacity) 85 } 86 content(usesRoomierType: usesRoomierType(for: geometry.size)) 87 } 88 .background(Color(.systemGroupedBackground)) 89 .animation(.easeInOut(duration: 0.3), value: announcements.currentGlobal()) 90 .animation(.easeInOut(duration: 0.3), value: showTipOptOut) 91 } 92 .navigationTitle("") 93 .navigationBarTitleDisplayMode(.inline) 94 .toolbar { 95 ToolbarItem(placement: .topBarLeading) { 96 Button { 97 showingSettings = true 98 } label: { 99 Image(systemName: "gearshape") 100 } 101 } 102 ToolbarItem(placement: .topBarTrailing) { 103 Button { 104 showingFriends = true 105 } label: { 106 Image(systemName: "person.2") 107 } 108 } 109 if #available(iOS 26.0, *) { 110 ToolbarSpacer(.fixed, placement: .topBarTrailing) 111 } 112 ToolbarItem(placement: .topBarTrailing) { 113 Button { 114 showingNewGame = true 115 } label: { 116 Image(systemName: "plus") 117 } 118 } 119 } 120 .sheet(isPresented: $showingSettings) { 121 SettingsView() 122 } 123 .sheet(isPresented: $showingFriends) { 124 FriendsView() 125 } 126 .sheet(isPresented: $showingNewGame) { 127 NewGameSheet(store: store) { gameID in 128 navigationPath.append(gameID) 129 } 130 } 131 .task { 132 await onAppear() 133 } 134 .onDisappear { 135 onDisappear() 136 } 137 .alert("Resign Puzzle?", isPresented: .init( 138 get: { resignTarget != nil }, 139 set: { if !$0 { resignTarget = nil } } 140 )) { 141 Button("Resign", role: .destructive) { 142 if let target = resignTarget { 143 do { 144 try store.resignGame(id: target.id) 145 let id = target.id 146 Task { await sendResignPings?(id) } 147 } catch { 148 announcements.post(Announcement( 149 id: Self.destructiveActionErrorID, 150 scope: .global, 151 severity: .error, 152 title: "Resigning Failed", 153 body: error.localizedDescription, 154 dismissal: .manual 155 )) 156 } 157 } 158 } 159 Button("Cancel", role: .cancel) {} 160 } message: { 161 if let target = resignTarget { 162 Text("This will reveal all answers for \"\(target.title)\".") 163 } 164 } 165 .alert("Leave Puzzle?", isPresented: .init( 166 get: { leaveTarget != nil }, 167 set: { if !$0 { leaveTarget = nil } } 168 )) { 169 Button("Leave", role: .destructive) { 170 if let target = leaveTarget { 171 Task { await leaveShare(game: target) } 172 } 173 } 174 Button("Cancel", role: .cancel) {} 175 } message: { 176 if let target = leaveTarget { 177 Text("You will lose access to \"\(target.title)\".") 178 } 179 } 180 .alert("Delete Puzzle?", isPresented: .init( 181 get: { deleteTarget != nil }, 182 set: { if !$0 { deleteTarget = nil } } 183 )) { 184 Button("Delete", role: .destructive) { 185 if let target = deleteTarget { 186 do { 187 try store.deleteGame(id: target.id) 188 } catch { 189 announcements.post(Announcement( 190 id: Self.destructiveActionErrorID, 191 scope: .global, 192 severity: .error, 193 title: "Deleting Failed", 194 body: error.localizedDescription, 195 dismissal: .manual 196 )) 197 } 198 } 199 } 200 Button("Cancel", role: .cancel) {} 201 } message: { 202 if let target = deleteTarget { 203 if target.isOwned && target.isShared { 204 Text("This will permanently delete \"\(target.title)\" from iCloud for everyone.") 205 } else { 206 Text("This will permanently delete \"\(target.title)\" and all progress.") 207 } 208 } 209 } 210 .alert("Block This Player?", isPresented: .init( 211 get: { blockTarget != nil }, 212 set: { if !$0 { blockTarget = nil } } 213 )) { 214 Button("Block", role: .destructive) { 215 if let target = blockTarget, let authorID = target.inviterAuthorID { 216 Task { await blockFriend?(authorID) } 217 } 218 } 219 Button("Cancel", role: .cancel) {} 220 } message: { 221 let name = blockTarget?.resolvedInviterName ?? "this player" 222 Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.") 223 } 224 .alert("Set Profile Name", isPresented: $showingNamePrompt) { 225 TextField("Name", text: $nameDraft) 226 .textInputAutocapitalization(.never) 227 .autocorrectionDisabled() 228 Button("Cancel", role: .cancel) {} 229 Button("Save") { 230 let trimmed = nameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 231 if !trimmed.isEmpty { 232 preferences.name = trimmed 233 nameDraft = trimmed 234 } 235 } 236 .keyboardShortcut(.defaultAction) 237 } message: { 238 Text("Enter the name other players will see.") 239 } 240 } 241 242 /// Dismisses the banner's announcement. For a tip, also records it as 243 /// dismissed so it never returns, and surfaces the "Never show me tips" 244 /// opt-out in its place for a few seconds. 245 private func dismissAnnouncement(_ announcement: Announcement) { 246 announcements.dismiss(id: announcement.id) 247 guard let tipID = Tip.tipID(fromAnnouncementID: announcement.id) else { return } 248 tips.markDismissed(tipID) 249 showTipOptOut = true 250 tipOptOutHideTask?.cancel() 251 tipOptOutHideTask = Task { @MainActor in 252 try? await Task.sleep(for: .seconds(6)) 253 guard !Task.isCancelled else { return } 254 showTipOptOut = false 255 } 256 } 257 258 @ViewBuilder 259 private func content(usesRoomierType: Bool) -> some View { 260 let summaries = games.compactMap { 261 summaryCache.summary( 262 for: $0, 263 localAuthorID: authorIdentity.currentID, 264 localName: preferences.name, 265 localColor: preferences.color 266 ) 267 } 268 let inProgress = summaries 269 .filter { $0.completedAt == nil && !$0.isAccessRevoked } 270 .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } 271 let revoked = summaries 272 .filter { $0.isAccessRevoked } 273 .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } 274 let completed = summaries 275 .filter { $0.completedAt != nil && !$0.isAccessRevoked } 276 .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } 277 let visibleCount = min(completedVisibleCount, completed.count) 278 let visibleCompleted = Array(completed.prefix(visibleCount)) 279 let hasMore = visibleCount < completed.count 280 281 let blockedIDs = Set(blockedFriends.compactMap { $0.authorID }) 282 let visibleInvites = pendingInvites.filter { 283 guard let inviter = $0.inviterAuthorID else { return true } 284 return !blockedIDs.contains(inviter) 285 } 286 287 Group { 288 if horizontalSizeClass == .regular { 289 gridLayout( 290 invites: visibleInvites, 291 inProgress: inProgress, 292 revoked: revoked, 293 completed: visibleCompleted, 294 hasMore: hasMore, 295 usesRoomierType: usesRoomierType 296 ) 297 } else { 298 listLayout( 299 invites: visibleInvites, 300 inProgress: inProgress, 301 revoked: revoked, 302 completed: visibleCompleted, 303 hasMore: hasMore, 304 usesRoomierType: usesRoomierType 305 ) 306 } 307 } 308 .overlay { 309 if games.isEmpty { 310 if preferences.hasName { 311 ContentUnavailableView { 312 Label("No Puzzles", systemImage: "square.grid.3x3") 313 } description: { 314 Text("Tap the + button to start a new puzzle, or pull down to refresh.") 315 } 316 } else { 317 ContentUnavailableView { 318 Label("Set Your Profile Name", systemImage: "person.text.rectangle") 319 } description: { 320 Text("Choose the name other players will see.") 321 } actions: { 322 Button { 323 nameDraft = "" 324 showingNamePrompt = true 325 } label: { Text("Set Profile Name") } 326 .buttonStyle(.borderedProminent) 327 } 328 } 329 } 330 } 331 .onChange(of: completed.count) { oldCount, newCount in 332 if newCount > oldCount { 333 completedVisibleCount += (newCount - oldCount) 334 } 335 } 336 } 337 338 // MARK: - List layout (compact width / iPhone) 339 340 @ViewBuilder 341 private func listLayout( 342 invites: [InviteEntity], 343 inProgress: [GameSummary], 344 revoked: [GameSummary], 345 completed: [GameSummary], 346 hasMore: Bool, 347 usesRoomierType: Bool 348 ) -> some View { 349 List { 350 if !invites.isEmpty { 351 Section { 352 ForEach(invites, id: \.objectID) { invite in 353 inviteRow(for: invite) 354 } 355 } header: { 356 Text("Invited") 357 } 358 } 359 360 if !inProgress.isEmpty { 361 Section { 362 ForEach(inProgress) { game in 363 rowView(for: game, usesRoomierType: usesRoomierType) 364 } 365 } header: { 366 Text("In Progress") 367 } 368 } 369 370 if !revoked.isEmpty { 371 Section { 372 ForEach(revoked) { game in 373 rowView(for: game, usesRoomierType: usesRoomierType) 374 } 375 } header: { 376 Text("Revoked") 377 } 378 } 379 380 if !completed.isEmpty { 381 Section { 382 ForEach(completed) { game in 383 rowView(for: game, usesRoomierType: usesRoomierType) 384 } 385 } header: { 386 Text("Completed") 387 } footer: { 388 if hasMore { 389 loadMoreButton 390 } 391 } 392 } 393 } 394 .refreshable { 395 await onRefresh() 396 } 397 } 398 399 // MARK: - Grid layout (regular width / iPad) 400 401 private var gridColumns: [GridItem] { 402 [GridItem(.adaptive(minimum: 320), spacing: 12)] 403 } 404 405 @ViewBuilder 406 private func gridLayout( 407 invites: [InviteEntity], 408 inProgress: [GameSummary], 409 revoked: [GameSummary], 410 completed: [GameSummary], 411 hasMore: Bool, 412 usesRoomierType: Bool 413 ) -> some View { 414 ScrollView { 415 LazyVStack(spacing: 8) { 416 if !invites.isEmpty { 417 Section { 418 LazyVGrid(columns: gridColumns, spacing: 12) { 419 ForEach(invites, id: \.objectID) { invite in 420 inviteCard(for: invite) 421 } 422 } 423 .padding(.horizontal) 424 } header: { 425 gridSectionHeader("Invited") 426 } 427 } 428 429 if !inProgress.isEmpty { 430 Section { 431 LazyVGrid(columns: gridColumns, spacing: 12) { 432 ForEach(inProgress) { game in 433 gameCard(for: game, usesRoomierType: usesRoomierType) 434 } 435 } 436 .padding(.horizontal) 437 } header: { 438 gridSectionHeader("In Progress") 439 } 440 } 441 442 if !revoked.isEmpty { 443 Section { 444 LazyVGrid(columns: gridColumns, spacing: 12) { 445 ForEach(revoked) { game in 446 gameCard(for: game, usesRoomierType: usesRoomierType) 447 } 448 } 449 .padding(.horizontal) 450 } header: { 451 gridSectionHeader("Revoked") 452 } 453 } 454 455 if !completed.isEmpty { 456 Section { 457 LazyVGrid(columns: gridColumns, spacing: 12) { 458 ForEach(completed) { game in 459 gameCard(for: game, usesRoomierType: usesRoomierType) 460 } 461 } 462 .padding(.horizontal) 463 464 if hasMore { 465 loadMoreButton 466 .padding(.horizontal) 467 } 468 } header: { 469 gridSectionHeader("Completed") 470 } 471 } 472 } 473 .padding(.vertical, 8) 474 } 475 .background(Color(.systemGroupedBackground)) 476 .refreshable { 477 await onRefresh() 478 } 479 } 480 481 private func gridSectionHeader(_ title: String) -> some View { 482 Text(title) 483 .font(.footnote.weight(.semibold)) 484 .foregroundStyle(.secondary) 485 .frame(maxWidth: .infinity, alignment: .leading) 486 .padding(.horizontal, 16) 487 .padding(.vertical, 8) 488 .background(Color(.systemGroupedBackground)) 489 } 490 491 private var loadMoreButton: some View { 492 HStack { 493 Spacer() 494 Button { 495 withAnimation(.easeInOut(duration: 0.25)) { 496 completedVisibleCount += Self.completedPageSize 497 } 498 } label: { 499 Text("Load More") 500 .font(.subheadline.weight(.semibold)) 501 .foregroundColor(.secondary) 502 .padding(.horizontal, 18) 503 .padding(.vertical, 8) 504 .background(Color(.tertiarySystemFill), in: Capsule()) 505 } 506 .buttonStyle(.plain) 507 .textCase(nil) 508 Spacer() 509 } 510 .padding(.top, 8) 511 } 512 513 private func gameCard(for game: GameSummary, usesRoomierType: Bool) -> some View { 514 GameCardView( 515 game: game, 516 shareController: shareController, 517 usesRoomierType: usesRoomierType, 518 onResume: { navigationPath.append(game.id) }, 519 onLeave: { leaveTarget = game }, 520 onResign: { resignTarget = game }, 521 onDelete: { deleteTarget = game } 522 ) 523 } 524 525 /// The puzzle-shape preview for an invite, decoded from the silhouette 526 /// segment the inviter sent. Open cells render grey (`.filled`) to read as 527 /// "not yet playable", matching the link-tap placeholder in 528 /// `JoiningPuzzleView`. An absent or undecodable grid gets no thumbnail. 529 @ViewBuilder 530 private func inviteThumbnail(for invite: InviteEntity) -> some View { 531 if let segment = invite.gridSilhouette, 532 let shape = GridSilhouette.decode(segment) { 533 GridThumbnailView( 534 width: shape.width, 535 height: shape.height, 536 cells: shape.blocks.map { $0 ? .block : .filled } 537 ) 538 } 539 } 540 541 @ViewBuilder 542 private func inviteCard(for invite: InviteEntity) -> some View { 543 let inviter = invite.resolvedInviterName ?? "A player" 544 let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" 545 HStack(spacing: 12) { 546 inviteThumbnail(for: invite) 547 VStack(alignment: .leading, spacing: 2) { 548 Text(title) 549 .font(.subheadline.weight(.semibold)) 550 .lineLimit(1) 551 .truncationMode(.tail) 552 Text("Invited by \(inviter)") 553 .font(.footnote) 554 .foregroundStyle(.secondary) 555 .lineLimit(1) 556 } 557 Spacer(minLength: 0) 558 if acceptingInviteID == invite.objectID { 559 ProgressView() 560 } else { 561 Button("Accept") { Task { await accept(invite) } } 562 .buttonStyle(.borderedProminent) 563 .controlSize(.small) 564 } 565 inviteMenu(for: invite) 566 } 567 .padding(12) 568 .frame(maxWidth: .infinity) 569 .frame(height: CardMetrics.height) 570 .background( 571 Color(.secondarySystemGroupedBackground), 572 in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) 573 ) 574 } 575 576 private func inviteMenu(for invite: InviteEntity) -> some View { 577 Menu { 578 Button { Task { await decline(invite) } } label: { 579 Label("Decline", systemImage: "xmark") 580 } 581 Button(role: .destructive) { blockTarget = invite } label: { 582 Label("Block", systemImage: "hand.raised") 583 } 584 } label: { 585 Text("More") 586 .foregroundStyle(.primary) 587 } 588 .buttonStyle(.bordered) 589 .controlSize(.small) 590 .tint(.secondary) 591 .compositingGroup() 592 } 593 594 @ViewBuilder 595 private func inviteRow(for invite: InviteEntity) -> some View { 596 let inviter = invite.resolvedInviterName ?? "A player" 597 let title = (invite.gameTitle?.isEmpty == false) ? invite.gameTitle! : "a puzzle" 598 HStack { 599 inviteThumbnail(for: invite) 600 VStack(alignment: .leading, spacing: 2) { 601 Text(title).font(.body.weight(.medium)) 602 Text("Invited by \(inviter)") 603 .font(.caption) 604 .foregroundStyle(.secondary) 605 } 606 Spacer() 607 if acceptingInviteID == invite.objectID { 608 ProgressView() 609 } else { 610 Button("Accept") { Task { await accept(invite) } } 611 .buttonStyle(.borderedProminent) 612 .controlSize(.small) 613 } 614 inviteMenu(for: invite) 615 } 616 .swipeActions(edge: .trailing) { 617 Button("Decline") { Task { await decline(invite) } } 618 .tint(.gray) 619 Button("Block", role: .destructive) { blockTarget = invite } 620 } 621 } 622 623 private func accept(_ invite: InviteEntity) async { 624 guard let url = invite.shareURL, 625 let ping = invite.pingRecordName 626 else { return } 627 let shape = invite.gridSilhouette.flatMap(GridSilhouette.decode) 628 acceptingInviteID = invite.objectID 629 announcements.dismiss(id: Self.inviteErrorID) 630 defer { acceptingInviteID = nil } 631 do { 632 if let onAcceptInvite { 633 try await onAcceptInvite(url, ping, shape) 634 } else if let acceptInvite { 635 try await acceptInvite(url, ping) 636 } 637 } catch { 638 announcements.post(Announcement( 639 id: Self.inviteErrorID, 640 scope: .global, 641 severity: .error, 642 title: "Accepting Failed", 643 body: error.localizedDescription, 644 dismissal: .manual 645 )) 646 } 647 } 648 649 /// Single-slot id for the invite-accept failure banner — a fresh 650 /// failure replaces the prior one rather than stacking. 651 private static let inviteErrorID = "invite-accept-error" 652 653 /// Single-slot id for game-list destructive-action failures (decline, 654 /// resign, delete) — a fresh failure replaces the prior one. 655 private static let destructiveActionErrorID = "game-list-destructive-action-error" 656 657 private func decline(_ invite: InviteEntity) async { 658 guard let declineInvite, let gameID = invite.gameID else { return } 659 do { 660 try await declineInvite(gameID) 661 } catch { 662 announcements.post(Announcement( 663 id: Self.destructiveActionErrorID, 664 scope: .global, 665 severity: .error, 666 title: "Declining Failed", 667 body: error.localizedDescription, 668 dismissal: .manual 669 )) 670 } 671 } 672 673 @ViewBuilder 674 private func rowView(for game: GameSummary, usesRoomierType: Bool) -> some View { 675 GameRowView( 676 game: game, 677 shareController: shareController, 678 usesRoomierType: usesRoomierType, 679 onResume: { navigationPath.append(game.id) }, 680 onLeave: { leaveTarget = game }, 681 onResign: { resignTarget = game }, 682 onDelete: { deleteTarget = game } 683 ) 684 .background( 685 NavigationLink(value: game.id) { EmptyView() } 686 .opacity(0) 687 ) 688 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 689 if !game.isOwned && game.isShared { 690 Button("Leave", role: .destructive) { 691 leaveTarget = game 692 } 693 } else { 694 Button("Delete", role: .destructive) { 695 deleteTarget = game 696 } 697 } 698 } 699 } 700 701 private func leaveShare(game: GameSummary) async { 702 do { 703 try await shareController.leaveShare(gameID: game.id) 704 leaveTarget = nil 705 } catch { 706 leaveError = error 707 leaveTarget = nil 708 } 709 } 710 711 private func usesRoomierType(for size: CGSize) -> Bool { 712 size.height >= 760 && dynamicTypeSize <= .medium 713 } 714 }