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