PuzzleView.swift (40997B)
1 import SwiftUI 2 3 struct PuzzleView: View { 4 @Bindable var session: PlayerSession 5 var shareController: ShareController? = nil 6 let roster: PlayerRoster 7 var onComplete: (() -> Void)? = nil 8 var onResign: (() throws -> Void)? = nil 9 var onDelete: (() throws -> Void)? = nil 10 @Environment(InputMonitor.self) private var inputMonitor 11 @Environment(PlayerPreferences.self) private var preferences 12 @Environment(\.dismiss) private var dismiss 13 @State private var isRenaming = false 14 @State private var renameDraft = "" 15 @State private var showErrorsAlert = false 16 @State private var isConfirmingResign = false 17 @State private var isConfirmingDelete = false 18 @State private var isConfirmingLeave = false 19 @State private var leaveError: String? 20 @State private var destructiveActionError: String? 21 @State private var isRevokedBannerDismissed = false 22 @State private var isShowingShareSheet = false 23 @State private var hasSolved = false 24 @State private var padLayout: PadLayout? 25 26 private enum PadLayout { 27 case landscape 28 case portrait 29 } 30 31 private func swatchImage(for color: PlayerColor) -> Image { 32 let tint = UIColor(color.tint) 33 let base = UIImage(systemName: "circle.fill") ?? UIImage() 34 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 35 } 36 37 private struct TitleParts { 38 let title: String 39 let subtitle: String? 40 } 41 42 private var titleParts: TitleParts { 43 let title = session.puzzle.title 44 let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) 45 let subtitle: String? 46 if let publisher = session.puzzle.publisher, let formattedDate { 47 subtitle = "\(publisher) · \(formattedDate)" 48 } else { 49 subtitle = formattedDate 50 } 51 return TitleParts(title: title, subtitle: subtitle) 52 } 53 54 private var isSolved: Bool { hasSolved } 55 56 var body: some View { 57 Group { 58 switch padLayout { 59 case .landscape: 60 landscapePadLayout 61 case .portrait: 62 portraitPadLayout 63 case .none: 64 phoneLayout 65 } 66 } 67 .overlay(alignment: .top) { 68 if session.mutator.isAccessRevoked && !isRevokedBannerDismissed { 69 AccessRevokedBanner { isRevokedBannerDismissed = true } 70 .transition(.move(edge: .top).combined(with: .opacity)) 71 } 72 } 73 .background(Color(.systemBackground)) 74 .background { 75 HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent) 76 .frame(width: 0, height: 0) 77 .allowsHitTesting(false) 78 } 79 .ignoresSafeArea(.keyboard) 80 .modifier(PuzzleToolbarModifier( 81 session: session, 82 roster: roster, 83 shareController: shareController, 84 isSolved: isSolved, 85 canResign: onResign != nil, 86 canDelete: onDelete != nil, 87 isRenaming: $isRenaming, 88 renameDraft: $renameDraft, 89 isConfirmingResign: $isConfirmingResign, 90 isConfirmingDelete: $isConfirmingDelete, 91 isConfirmingLeave: $isConfirmingLeave, 92 isShowingShareSheet: $isShowingShareSheet 93 )) 94 .modifier(PuzzleLifecycleModifier( 95 session: session, 96 roster: roster, 97 hasSolved: $hasSolved, 98 onCompletionStateChanged: handleCompletionState 99 )) 100 .modifier(PuzzlePresentationModifier( 101 session: session, 102 shareController: shareController, 103 isRenaming: $isRenaming, 104 renameDraft: $renameDraft, 105 showErrorsAlert: $showErrorsAlert, 106 isConfirmingResign: $isConfirmingResign, 107 isConfirmingDelete: $isConfirmingDelete, 108 isConfirmingLeave: $isConfirmingLeave, 109 leaveError: $leaveError, 110 destructiveActionError: $destructiveActionError, 111 isShowingShareSheet: $isShowingShareSheet, 112 performResign: performResign, 113 performDelete: performDelete, 114 leaveSharedGame: leaveSharedGame 115 )) 116 .onGeometryChange(for: CGSize.self) { proxy in 117 proxy.size 118 } action: { newSize in 119 updateLayoutTrait(for: newSize) 120 } 121 } 122 123 private var phoneLayout: some View { 124 VStack(spacing: 0) { 125 puzzleArea 126 controlsArea(showClueBar: true) 127 } 128 } 129 130 private var landscapePadLayout: some View { 131 VStack(spacing: 0) { 132 HStack(spacing: 0) { 133 VStack(spacing: 0) { 134 if !isSolved { 135 PuzzleScoreboard(session: session, roster: roster) 136 137 Divider() 138 } 139 140 ClueList(session: session, presentation: .sidebar) 141 } 142 .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) 143 .background(Color(.secondarySystemBackground)) 144 145 Divider() 146 .ignoresSafeArea(edges: .top) 147 148 puzzleArea 149 .padding(.bottom, 12) 150 .frame(maxWidth: .infinity, maxHeight: .infinity) 151 } 152 .frame(maxWidth: .infinity, maxHeight: .infinity) 153 154 controlsArea(showClueBar: false) 155 } 156 } 157 158 private var portraitPadLayout: some View { 159 VStack(spacing: 0) { 160 WeightedVStack(weights: [3, 1]) { 161 puzzleArea 162 .frame(maxWidth: .infinity, maxHeight: .infinity) 163 .padding(.bottom, 12) 164 165 VStack(spacing: 0) { 166 Divider() 167 168 HStack(alignment: .top, spacing: 0) { 169 if !isSolved { 170 PuzzleScoreboard(session: session, roster: roster) 171 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) 172 173 Divider() 174 } 175 176 ClueList(session: session, presentation: .sidebar) 177 .frame(maxWidth: .infinity, maxHeight: .infinity) 178 } 179 .background(Color(.secondarySystemBackground)) 180 } 181 } 182 .frame(maxWidth: .infinity, maxHeight: .infinity) 183 184 controlsArea(showClueBar: false) 185 } 186 } 187 188 private func updateLayoutTrait(for size: CGSize) { 189 guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { 190 padLayout = nil 191 return 192 } 193 padLayout = size.width > size.height ? .landscape : .portrait 194 } 195 196 private func performResign() { 197 do { 198 try onResign?() 199 dismiss() 200 } catch { 201 destructiveActionError = String(describing: error) 202 } 203 } 204 205 private func performDelete() { 206 do { 207 try onDelete?() 208 dismiss() 209 } catch { 210 destructiveActionError = String(describing: error) 211 } 212 } 213 214 private func handleCompletionState(_ newValue: Game.CompletionState) { 215 switch newValue { 216 case .incomplete: 217 break 218 case .filledWithErrors: 219 showErrorsAlert = true 220 case .solved: 221 guard !hasSolved else { return } 222 hasSolved = true 223 if session.isPencilMode { 224 session.togglePencil() 225 } 226 Task { @MainActor in 227 onComplete?() 228 } 229 } 230 } 231 232 private var puzzleArea: some View { 233 ZStack { 234 VStack(spacing: 4) { 235 PuzzleTitle(title: titleParts.title, subtitle: titleParts.subtitle) 236 GridView( 237 session: session, 238 roster: roster, 239 showsSharedAnnotations: session.mutator.isShared 240 ) 241 } 242 .frame(maxWidth: .infinity, maxHeight: .infinity) 243 .padding(.top, -8) 244 245 if session.isRebusActive { 246 Color.black.opacity(0.35) 247 .ignoresSafeArea(edges: .top) 248 .contentShape(Rectangle()) 249 .onTapGesture { 250 session.commitRebus() 251 } 252 RebusModal(text: session.rebusBuffer) 253 .padding(.horizontal) 254 .contentShape(Rectangle()) 255 .onTapGesture { /* swallow */ } 256 } 257 } 258 } 259 260 private func controlsArea(showClueBar: Bool) -> some View { 261 VStack(spacing: 0) { 262 if showClueBar { 263 ClueBarSlot(session: session) 264 } 265 controlsPanel 266 .frame(height: controlsPanelHeight) 267 } 268 } 269 270 private var controlsPanel: some View { 271 ZStack(alignment: .top) { 272 if isSolved { 273 ControlsView(height: controlsPanelHeight) { 274 SuccessPanel(session: session, roster: roster) 275 } 276 .transition(.move(edge: .bottom)) 277 } else if showsCustomKeyboard { 278 ControlsView(height: controlsPanelHeight) { 279 KeyboardView(session: session, showsNavigationKeys: padLayout != nil) 280 } 281 .transition(.move(edge: .bottom)) 282 } 283 } 284 .frame(height: controlsPanelHeight, alignment: .top) 285 .background { 286 Color(.systemGroupedBackground) 287 .ignoresSafeArea(edges: .bottom) 288 } 289 .overlay(alignment: .top) { 290 if controlsPanelHeight > 0 { 291 Rectangle() 292 .fill(Color(.opaqueSeparator)) 293 .frame(height: 0.5) 294 } 295 } 296 .animation(.easeOut(duration: 0.25), value: isSolved) 297 .ignoresSafeArea(edges: .bottom) 298 } 299 300 private var controlsPanelHeight: CGFloat { 301 isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 302 } 303 304 private var showsCustomKeyboard: Bool { 305 !inputMonitor.isConnected 306 } 307 308 private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { 309 guard !isSolved else { return false } 310 311 switch event.keyCode { 312 case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, 313 .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, 314 .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, 315 .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, 316 .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, 317 .keyboardZ: 318 guard !event.modifierFlags.contains(.command), 319 !event.modifierFlags.contains(.control), 320 !event.modifierFlags.contains(.alternate), 321 let letter = hardwareKeyboardLetter(from: event) else { 322 return false 323 } 324 if session.isRebusActive { 325 session.appendRebusLetter(letter) 326 } else { 327 session.enter(letter) 328 } 329 return true 330 331 case .keyboardDeleteOrBackspace, .keyboardDeleteForward: 332 if session.isRebusActive { 333 session.deleteRebusLetter() 334 } else { 335 session.deleteBackward() 336 } 337 return true 338 339 case .keyboardLeftArrow: 340 guard !session.isRebusActive else { return false } 341 if event.modifierFlags.contains(.command) { 342 session.goToPreviousWord() 343 return true 344 } 345 moveWithHardwareArrow(direction: .across) { 346 session.goToPreviousLetter() 347 } 348 return true 349 350 case .keyboardRightArrow: 351 guard !session.isRebusActive else { return false } 352 if event.modifierFlags.contains(.command) { 353 session.goToNextWord() 354 return true 355 } 356 moveWithHardwareArrow(direction: .across) { 357 session.goToNextLetter() 358 } 359 return true 360 361 case .keyboardUpArrow: 362 guard !session.isRebusActive else { return false } 363 moveWithHardwareArrow(direction: .down) { 364 session.goToPreviousLetter() 365 } 366 return true 367 368 case .keyboardDownArrow: 369 guard !session.isRebusActive else { return false } 370 moveWithHardwareArrow(direction: .down) { 371 session.goToNextLetter() 372 } 373 return true 374 375 case .keyboardTab: 376 guard !session.isRebusActive else { return false } 377 if event.modifierFlags.contains(.shift) { 378 session.goToPreviousClue() 379 } else { 380 session.goToNextClue() 381 } 382 return true 383 384 case .keyboardSpacebar: 385 guard !session.isRebusActive else { return false } 386 session.toggleDirection() 387 return true 388 389 case .keyboardReturnOrEnter: 390 if session.isRebusActive { 391 session.commitRebus() 392 } else { 393 session.toggleDirection() 394 } 395 return true 396 397 case .keyboardEscape: 398 if session.isRebusActive { 399 session.commitRebus() 400 return true 401 } 402 return false 403 404 default: 405 return false 406 } 407 } 408 409 private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? { 410 let scalars = event.charactersIgnoringModifiers.unicodeScalars 411 guard scalars.count == 1, let scalar = scalars.first else { return nil } 412 413 switch scalar.value { 414 case 65...90, 97...122: 415 return String(Character(scalar)).uppercased() 416 default: 417 return nil 418 } 419 } 420 421 private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { 422 if session.direction != direction { 423 let previousDirection = session.direction 424 session.setDirection(direction) 425 if session.direction != previousDirection { 426 return 427 } 428 } 429 430 move() 431 } 432 433 private func leaveSharedGame() async { 434 guard let shareController else { return } 435 do { 436 try await shareController.leaveShare(gameID: session.mutator.gameID) 437 dismiss() 438 } catch { 439 leaveError = String(describing: error) 440 } 441 } 442 } 443 444 private struct PuzzleScoreboard: View { 445 @Bindable var session: PlayerSession 446 let roster: PlayerRoster 447 @Environment(PlayerPreferences.self) private var preferences 448 449 private struct Score: Identifiable { 450 let authorID: String? 451 let name: String 452 let color: PlayerColor? 453 let filledCount: Int 454 455 var id: String { authorID ?? "unattributed" } 456 } 457 458 private var fillableCellCount: Int { 459 session.puzzle.cells.reduce(0) { count, row in 460 count + row.filter { !$0.isBlock }.count 461 } 462 } 463 464 private var filledCellCount: Int { 465 var count = 0 466 for r in 0..<session.puzzle.height { 467 for c in 0..<session.puzzle.width { 468 guard !session.puzzle.cells[r][c].isBlock else { continue } 469 if !session.game.squares[r][c].entry.isEmpty { 470 count += 1 471 } 472 } 473 } 474 return count 475 } 476 477 private var revealedSquareCount: Int { 478 var count = 0 479 for r in 0..<session.puzzle.height { 480 for c in 0..<session.puzzle.width { 481 guard !session.puzzle.cells[r][c].isBlock else { continue } 482 if session.game.squares[r][c].mark.isRevealed { 483 count += 1 484 } 485 } 486 } 487 return count 488 } 489 490 private var remainingCount: Int { 491 max(0, fillableCellCount - filledCellCount) 492 } 493 494 private var remainingPhrase: String { 495 switch remainingCount { 496 case 0: 497 return "no squares to go" 498 case 1: 499 return "1 square to go" 500 default: 501 return "\(remainingCount) squares to go" 502 } 503 } 504 505 private var revealedPhrase: String { 506 switch revealedSquareCount { 507 case 0: 508 return "No squares revealed" 509 case 1: 510 return "1 square revealed" 511 default: 512 return "\(revealedSquareCount) squares revealed" 513 } 514 } 515 516 private var progressText: String { 517 if revealedSquareCount > 0 { 518 return "\(revealedPhrase), \(remainingPhrase)" 519 } 520 switch remainingCount { 521 case 0: 522 return "No squares to go" 523 case 1: 524 return "1 square to go" 525 default: 526 return "\(remainingCount) squares to go" 527 } 528 } 529 530 private var scores: [Score] { 531 var counts: [String?: Int] = [:] 532 for r in 0..<session.puzzle.height { 533 for c in 0..<session.puzzle.width { 534 guard !session.puzzle.cells[r][c].isBlock else { continue } 535 let square = session.game.squares[r][c] 536 guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } 537 counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 538 } 539 } 540 541 let entries = roster.entries 542 let usesLocalFallback = entries.isEmpty 543 let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) 544 let rosterAuthorIDs = Set(entries.map(\.authorID)) 545 546 let rosterScores: [Score] 547 if usesLocalFallback { 548 rosterScores = [ 549 Score( 550 authorID: nil, 551 name: preferences.name, 552 color: preferences.color, 553 filledCount: counts[nil] ?? 0 554 ) 555 ] 556 } else { 557 rosterScores = entries.map { entry in 558 Score( 559 authorID: entry.authorID, 560 name: entry.name, 561 color: entry.color, 562 filledCount: counts[entry.authorID] ?? 0 563 ) 564 } 565 } 566 567 let extraScores = counts.compactMap { authorID, count -> Score? in 568 if let authorID, rosterAuthorIDs.contains(authorID) { 569 return nil 570 } 571 if authorID == nil && usesLocalFallback { 572 return nil 573 } 574 if let authorID, let entry = entryByAuthorID[authorID] { 575 return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) 576 } 577 if authorID == nil { 578 return Score(authorID: nil, name: "Unattributed", color: nil, filledCount: count) 579 } 580 return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) 581 } 582 583 return (rosterScores + extraScores) 584 .sorted { 585 if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } 586 return $0.name < $1.name 587 } 588 } 589 590 private func normalizedAuthorID(_ authorID: String?) -> String? { 591 guard let authorID else { 592 return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID 593 } 594 return authorID 595 } 596 597 var body: some View { 598 VStack(alignment: .leading, spacing: 12) { 599 Text("Players") 600 .font(.headline) 601 602 VStack(alignment: .leading, spacing: 6) { 603 ForEach(scores) { score in 604 scoreRow(score) 605 } 606 607 Text(progressText) 608 .font(.footnote) 609 .foregroundStyle(.secondary) 610 .padding(.top, 10) 611 .frame(maxWidth: .infinity, alignment: .center) 612 } 613 } 614 .padding(.horizontal, 18) 615 .padding(.vertical, 14) 616 .frame(maxWidth: .infinity, alignment: .leading) 617 } 618 619 private func scoreRow(_ score: Score) -> some View { 620 HStack(spacing: 8) { 621 Circle() 622 .fill(score.color?.tint ?? Color.secondary) 623 .frame(width: 8, height: 8) 624 Text(score.name) 625 .font(.subheadline) 626 .lineLimit(1) 627 Spacer(minLength: 8) 628 Text("\(score.filledCount)") 629 .font(.subheadline.monospacedDigit().weight(.semibold)) 630 } 631 .accessibilityElement(children: .combine) 632 } 633 } 634 635 private struct PuzzleToolbarModifier: ViewModifier { 636 let session: PlayerSession 637 let roster: PlayerRoster 638 let shareController: ShareController? 639 let isSolved: Bool 640 let canResign: Bool 641 let canDelete: Bool 642 @Binding var isRenaming: Bool 643 @Binding var renameDraft: String 644 @Binding var isConfirmingResign: Bool 645 @Binding var isConfirmingDelete: Bool 646 @Binding var isConfirmingLeave: Bool 647 @Binding var isShowingShareSheet: Bool 648 @Environment(PlayerPreferences.self) private var preferences 649 @AppStorage("debugMode") private var debugMode = false 650 651 func body(content: Content) -> some View { 652 content.toolbar { 653 ToolbarItemGroup(placement: .topBarTrailing) { 654 pencilButton 655 entryMenu 656 hintsMenu 657 playersMenu 658 } 659 } 660 } 661 662 private func swatchImage(for color: PlayerColor) -> Image { 663 let tint = UIColor(color.tint) 664 let base = UIImage(systemName: "circle.fill") ?? UIImage() 665 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 666 } 667 668 private var pencilButton: some View { 669 Button { 670 session.togglePencil() 671 } label: { 672 Image(systemName: "pencil") 673 .foregroundStyle(pencilButtonForeground) 674 .padding(6) 675 .glassEffect( 676 !isSolved && session.isPencilMode 677 ? .regular.tint(preferences.color.tint) 678 : .identity, 679 in: Circle() 680 ) 681 } 682 .accessibilityLabel("Pencil") 683 .disabled(isSolved) 684 } 685 686 private var pencilButtonForeground: Color { 687 if isSolved { 688 return .secondary 689 } 690 return session.isPencilMode ? .white : .primary 691 } 692 693 private var entryMenu: some View { 694 Menu { 695 Section { 696 Button("Enter Rebus") { session.startRebus() } 697 Button("Toggle Direction") { session.toggleDirection() } 698 } 699 700 if debugMode { 701 Section { 702 NavigationLink { 703 DiagnosticsView() 704 } label: { 705 Text("iCloud Diagnostics") 706 } 707 } 708 } 709 710 Section { 711 Button("Clear Word") { session.clearCurrentWord() } 712 Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } 713 } 714 } label: { 715 Label("Entry", systemImage: "squareshape.split.2x2") 716 } 717 .disabled(isSolved) 718 } 719 720 private var hintsMenu: some View { 721 Menu { 722 Section { 723 Button("Check Square") { session.checkSquare() } 724 Button("Check Word") { session.checkCurrentWord() } 725 Button("Check Puzzle") { session.checkPuzzle() } 726 } 727 Section { 728 Button("Reveal Square") { session.revealSquare() } 729 Button("Reveal Word") { session.revealCurrentWord() } 730 Button("Reveal Puzzle") { session.revealPuzzle() } 731 } 732 } label: { 733 Label("Hints", systemImage: "lightbulb") 734 } 735 .disabled(isSolved) 736 } 737 738 private var playersMenu: some View { 739 Menu { 740 playerRosterSection 741 playerPreferencesSection 742 shareSection 743 puzzleDestructiveSection 744 } label: { 745 Label("Players", systemImage: "person.2") 746 } 747 .disabled(isSolved) 748 } 749 750 @ViewBuilder 751 private var playerRosterSection: some View { 752 Section { 753 if !roster.entries.isEmpty { 754 ForEach(roster.entries) { entry in 755 Button {} label: { 756 Label { 757 Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) 758 } icon: { 759 swatchImage(for: entry.color) 760 } 761 } 762 .disabled(true) 763 } 764 } else { 765 Button {} label: { 766 Label { 767 Text(preferences.name) 768 } icon: { 769 swatchImage(for: preferences.color) 770 } 771 } 772 .disabled(true) 773 } 774 } 775 } 776 777 private var playerPreferencesSection: some View { 778 Section { 779 Menu("Change Colour") { 780 ForEach(PlayerColor.palette) { color in 781 Button { 782 preferences.color = color 783 Task { await roster.reassignOnLocalColorChange(newColor: color) } 784 } label: { 785 Label { 786 Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) 787 } icon: { 788 swatchImage(for: color) 789 } 790 } 791 } 792 } 793 794 Button("Change Name") { 795 renameDraft = preferences.name 796 isRenaming = true 797 } 798 } 799 } 800 801 @ViewBuilder 802 private var shareSection: some View { 803 if shareController != nil { 804 Section { 805 Button { 806 isShowingShareSheet = true 807 } label: { 808 Text("Share Game") 809 } 810 .disabled(!session.mutator.isOwned) 811 } 812 } 813 } 814 815 private var puzzleDestructiveSection: some View { 816 Section { 817 Button("Resign Game", role: .destructive) { 818 isConfirmingResign = true 819 } 820 .disabled(isSolved || !canResign) 821 822 if session.mutator.isShared && !session.mutator.isOwned { 823 Button("Leave Game", role: .destructive) { 824 isConfirmingLeave = true 825 } 826 .disabled(shareController == nil) 827 } else { 828 Button("Delete Game", role: .destructive) { 829 isConfirmingDelete = true 830 } 831 .disabled(!canDelete) 832 } 833 } 834 } 835 } 836 837 private struct PuzzleLifecycleModifier: ViewModifier { 838 let session: PlayerSession 839 let roster: PlayerRoster 840 @Binding var hasSolved: Bool 841 let onCompletionStateChanged: (Game.CompletionState) -> Void 842 843 func body(content: Content) -> some View { 844 content 845 .task { 846 await roster.refresh() 847 } 848 .onAppear { 849 if session.game.completionState == .solved { 850 onCompletionStateChanged(.solved) 851 } 852 } 853 .onChange(of: session.game.completionState) { _, newValue in 854 onCompletionStateChanged(newValue) 855 } 856 .onAppear { 857 session.onCompletionStateChanged = { newValue in 858 onCompletionStateChanged(newValue) 859 } 860 } 861 .onDisappear { 862 session.onCompletionStateChanged = nil 863 } 864 } 865 } 866 867 private struct PuzzlePresentationModifier: ViewModifier { 868 let session: PlayerSession 869 let shareController: ShareController? 870 @Binding var isRenaming: Bool 871 @Binding var renameDraft: String 872 @Binding var showErrorsAlert: Bool 873 @Binding var isConfirmingResign: Bool 874 @Binding var isConfirmingDelete: Bool 875 @Binding var isConfirmingLeave: Bool 876 @Binding var leaveError: String? 877 @Binding var destructiveActionError: String? 878 @Binding var isShowingShareSheet: Bool 879 let performResign: () -> Void 880 let performDelete: () -> Void 881 let leaveSharedGame: () async -> Void 882 @Environment(PlayerPreferences.self) private var preferences 883 884 func body(content: Content) -> some View { 885 content 886 .alert("Not Quite Right", isPresented: $showErrorsAlert) { 887 Button("OK", role: .cancel) {} 888 } message: { 889 Text("One or more squares are incorrect.") 890 } 891 .alert("Resign Puzzle?", isPresented: $isConfirmingResign) { 892 Button("Resign", role: .destructive) { 893 performResign() 894 } 895 Button("Cancel", role: .cancel) {} 896 } message: { 897 Text("This will reveal the puzzle and mark it complete.") 898 } 899 .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) { 900 Button("Delete", role: .destructive) { 901 performDelete() 902 } 903 Button("Cancel", role: .cancel) {} 904 } message: { 905 deleteConfirmationMessage 906 } 907 .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { 908 Button("Leave", role: .destructive) { 909 Task { await leaveSharedGame() } 910 } 911 Button("Cancel", role: .cancel) {} 912 } message: { 913 Text("You will lose access to \"\(session.puzzle.title)\".") 914 } 915 .alert( 916 "Couldn't Leave", 917 isPresented: .init( 918 get: { leaveError != nil }, 919 set: { if !$0 { leaveError = nil } } 920 ), 921 presenting: leaveError 922 ) { _ in 923 Button("OK", role: .cancel) {} 924 } message: { message in 925 Text(message) 926 } 927 .alert( 928 "Couldn't Update Puzzle", 929 isPresented: .init( 930 get: { destructiveActionError != nil }, 931 set: { if !$0 { destructiveActionError = nil } } 932 ), 933 presenting: destructiveActionError 934 ) { _ in 935 Button("OK", role: .cancel) {} 936 } message: { message in 937 Text(message) 938 } 939 .alert("Change Name", isPresented: $isRenaming) { 940 TextField("Name", text: $renameDraft) 941 .textInputAutocapitalization(.never) 942 .autocorrectionDisabled() 943 Button("Cancel", role: .cancel) {} 944 Button("Save") { 945 let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 946 if !trimmed.isEmpty { 947 preferences.name = trimmed 948 } 949 } 950 .keyboardShortcut(.defaultAction) 951 } message: { 952 Text("Enter the name other players will see.") 953 } 954 .sheet(isPresented: $isShowingShareSheet) { 955 if let shareController { 956 GameShareSheet( 957 gameID: session.mutator.gameID, 958 title: session.puzzle.title, 959 shareController: shareController 960 ) 961 } 962 } 963 } 964 965 private var deleteConfirmationMessage: Text { 966 if session.mutator.isOwned && session.mutator.isShared { 967 Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.") 968 } else { 969 Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.") 970 } 971 } 972 } 973 974 private struct PuzzleTitle: View { 975 let title: String 976 let subtitle: String? 977 978 var body: some View { 979 VStack(spacing: 2) { 980 Text(title) 981 .font(.headline) 982 .lineLimit(2) 983 if let subtitle { 984 Text(subtitle) 985 .font(.subheadline) 986 .foregroundStyle(.secondary) 987 .lineLimit(1) 988 } 989 } 990 .multilineTextAlignment(.center) 991 .frame(maxWidth: .infinity) 992 .padding(.horizontal) 993 .padding(.bottom, 12) 994 } 995 } 996 997 private struct ClueKey: Hashable { 998 let direction: Puzzle.Direction 999 let number: Int 1000 } 1001 1002 private struct ClueBarSlot: View { 1003 @Bindable var session: PlayerSession 1004 1005 var body: some View { 1006 ZStack(alignment: .bottom) { 1007 ClueBarReservation() 1008 1009 ClueBar(session: session) 1010 } 1011 } 1012 } 1013 1014 private struct ClueBarReservation: View { 1015 var body: some View { 1016 ClueBarContent( 1017 label: "99 Across", 1018 clueText: "Clue reservation", 1019 reservesClueSpace: true 1020 ) 1021 .opacity(0) 1022 .accessibilityHidden(true) 1023 .allowsHitTesting(false) 1024 } 1025 } 1026 1027 private struct ClueBarContent: View { 1028 let label: String 1029 let clueText: String 1030 var reservesClueSpace = false 1031 var currentKey: ClueKey? 1032 var slideEdge: Edge = .trailing 1033 var onPrevious: (() -> Void)? 1034 var onNext: (() -> Void)? 1035 var onClueTap: (() -> Void)? 1036 var onLabelTap: (() -> Void)? 1037 1038 var body: some View { 1039 HStack(alignment: .clueCenter, spacing: 8) { 1040 ClueBarIcon(systemName: "chevron.left", action: onPrevious) 1041 1042 VStack(alignment: .leading, spacing: 4) { 1043 Text(label) 1044 .font(.caption) 1045 .textCase(.uppercase) 1046 .foregroundStyle(.secondary) 1047 .contentShape(Rectangle()) 1048 .highPriorityGesture( 1049 TapGesture() 1050 .onEnded { 1051 onLabelTap?() 1052 } 1053 ) 1054 ZStack(alignment: .leading) { 1055 clueTextView 1056 } 1057 .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } 1058 .frame(maxWidth: .infinity, alignment: .leading) 1059 .clipped() 1060 } 1061 .contentShape(Rectangle()) 1062 .onTapGesture { 1063 onClueTap?() 1064 } 1065 1066 ClueBarIcon(systemName: "chevron.right", action: onNext) 1067 } 1068 .padding(.horizontal, 8) 1069 .padding(.top, 12) 1070 .padding(.bottom, 6) 1071 } 1072 1073 @ViewBuilder 1074 private var clueTextView: some View { 1075 baseClueText 1076 .id(currentKey) 1077 .transition(.asymmetric( 1078 insertion: .move(edge: slideEdge), 1079 removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) 1080 )) 1081 } 1082 1083 private var baseClueText: some View { 1084 Text(clueText) 1085 .font(.headline) 1086 .lineLimit(2, reservesSpace: reservesClueSpace) 1087 .multilineTextAlignment(.leading) 1088 .frame(maxWidth: .infinity, alignment: .leading) 1089 } 1090 } 1091 1092 private struct ClueBarIcon: View { 1093 let systemName: String 1094 var action: (() -> Void)? 1095 1096 var body: some View { 1097 if let action { 1098 Button(action: action) { 1099 icon 1100 } 1101 .buttonStyle(.plain) 1102 } else { 1103 icon 1104 } 1105 } 1106 1107 private var icon: some View { 1108 Image(systemName: systemName) 1109 .font(.title3.weight(.semibold)) 1110 .frame(width: 44, height: 44) 1111 .contentShape(Rectangle()) 1112 } 1113 } 1114 1115 private struct ClueBar: View { 1116 @Bindable var session: PlayerSession 1117 @Environment(PlayerPreferences.self) private var preferences 1118 @State private var slideEdge: Edge = .trailing 1119 @State private var isShowingClueList = false 1120 1121 private var playerColor: PlayerColor { preferences.color } 1122 1123 var body: some View { 1124 let clue = session.currentClue() 1125 let currentKey = clue.map { ClueKey(direction: session.direction, number: $0.number) } 1126 1127 ClueBarContent( 1128 label: label(for: clue), 1129 clueText: clue?.text ?? "—", 1130 currentKey: currentKey, 1131 slideEdge: slideEdge, 1132 onPrevious: { 1133 slideEdge = .leading 1134 session.goToPreviousClue() 1135 }, 1136 onNext: { 1137 slideEdge = .trailing 1138 session.goToNextClue() 1139 }, 1140 onClueTap: { 1141 isShowingClueList = true 1142 }, 1143 onLabelTap: { 1144 session.toggleDirection() 1145 } 1146 ) 1147 .background(playerColor.highlightFill) 1148 .animation(.smooth(duration: 0.22), value: currentKey) 1149 .sheet(isPresented: $isShowingClueList) { 1150 ClueList(session: session) 1151 .presentationDetents([.medium, .large]) 1152 .presentationDragIndicator(.visible) 1153 } 1154 } 1155 1156 private func label(for clue: Puzzle.Clue?) -> String { 1157 let direction = session.direction == .across ? "Across" : "Down" 1158 if let clue { 1159 return "\(clue.number) \(direction)" 1160 } 1161 return direction 1162 } 1163 } 1164 1165 private struct RebusModal: View { 1166 let text: String 1167 1168 var body: some View { 1169 Text(text.isEmpty ? " " : text) 1170 .font(.system(size: 32, weight: .semibold, design: .rounded)) 1171 .foregroundStyle(.primary) 1172 .frame(maxWidth: .infinity, minHeight: 56) 1173 .padding(.horizontal, 16) 1174 .background(Color(.systemBackground)) 1175 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 1176 .padding(20) 1177 .frame(maxWidth: .infinity) 1178 .background(Color(.secondarySystemBackground)) 1179 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) 1180 } 1181 } 1182 1183 private struct ControlsView<Content: View>: View { 1184 let height: CGFloat 1185 @ViewBuilder var content: () -> Content 1186 1187 var body: some View { 1188 VStack(spacing: 0) { 1189 content() 1190 .frame(height: height) 1191 Color(.systemGroupedBackground) 1192 } 1193 .background(Color(.systemGroupedBackground)) 1194 .ignoresSafeArea(edges: .bottom) 1195 } 1196 } 1197 1198 private extension VerticalAlignment { 1199 enum ClueCenterID: AlignmentID { 1200 static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } 1201 } 1202 static let clueCenter = VerticalAlignment(ClueCenterID.self) 1203 } 1204 1205 private struct AccessRevokedBanner: View { 1206 let onDismiss: () -> Void 1207 1208 var body: some View { 1209 HStack(spacing: 8) { 1210 Image(systemName: "person.slash") 1211 Text("This puzzle is no longer shared with you.") 1212 .font(.footnote) 1213 .frame(maxWidth: .infinity, alignment: .leading) 1214 Button { 1215 onDismiss() 1216 } label: { 1217 Image(systemName: "xmark") 1218 .font(.footnote.weight(.semibold)) 1219 } 1220 .buttonStyle(.plain) 1221 } 1222 .padding(.horizontal, 16) 1223 .padding(.vertical, 10) 1224 .background(Color(.secondarySystemBackground)) 1225 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 1226 .padding(.horizontal, 16) 1227 .padding(.top, 8) 1228 } 1229 } 1230 1231 private struct WeightedVStack: Layout { 1232 let weights: [CGFloat] 1233 1234 func sizeThatFits( 1235 proposal: ProposedViewSize, 1236 subviews: Subviews, 1237 cache: inout () 1238 ) -> CGSize { 1239 CGSize( 1240 width: proposal.width ?? 0, 1241 height: proposal.height ?? 0 1242 ) 1243 } 1244 1245 func placeSubviews( 1246 in bounds: CGRect, 1247 proposal: ProposedViewSize, 1248 subviews: Subviews, 1249 cache: inout () 1250 ) { 1251 let totalWeight = weights.reduce(0, +) 1252 guard totalWeight > 0 else { return } 1253 1254 var y = bounds.minY 1255 for (index, subview) in subviews.enumerated() { 1256 let weight = index < weights.count ? weights[index] : 0 1257 let height = bounds.height * weight / totalWeight 1258 subview.place( 1259 at: CGPoint(x: bounds.minX, y: y), 1260 anchor: .topLeading, 1261 proposal: ProposedViewSize(width: bounds.width, height: height) 1262 ) 1263 y += height 1264 } 1265 } 1266 }