PuzzleView.swift (60472B)
1 import SwiftUI 2 3 private enum RevealScope { 4 case square 5 case word 6 case puzzle 7 8 var title: String { 9 switch self { 10 case .square: "Reveal Square?" 11 case .word: "Reveal Word?" 12 case .puzzle: "Reveal Puzzle?" 13 } 14 } 15 16 var message: String { 17 switch self { 18 case .square: "This will reveal the current square." 19 case .word: "This will reveal the current word." 20 case .puzzle: "This will reveal the entire puzzle and mark it complete." 21 } 22 } 23 } 24 25 struct PuzzleView: View { 26 @Bindable var session: PlayerSession 27 var shareController: ShareController? = nil 28 let roster: PlayerRoster 29 var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil 30 var onResign: (() throws -> Void)? = nil 31 var onDelete: (() throws -> Void)? = nil 32 /// Loads the finished game's merged journal for the finish-banner replay 33 /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. 34 var loadReplay: () async -> JournalReplayResult = { .unavailable } 35 /// Cells a peer filled or cleared since this player last viewed the puzzle, 36 /// mapped to the writing author. Read once on the open arm beat. Defaults to 37 /// empty so previews/tests need not wire it. 38 var loadRecentChanges: () -> [GridPosition: String] = { [:] } 39 /// Stamps this game's last-viewed timestamp (device-local). Called when the 40 /// away-change borders are acknowledged. Defaults to a no-op. 41 var markPuzzleViewed: () -> Void = {} 42 @Environment(InputMonitor.self) private var inputMonitor 43 @Environment(PlayerPreferences.self) private var preferences 44 @Environment(AnnouncementCenter.self) private var announcements 45 @Environment(\.dismiss) private var dismiss 46 @State private var isRenaming = false 47 @State private var renameDraft = "" 48 @State private var showErrorsAlert = false 49 @State private var isConfirmingResign = false 50 @State private var isConfirmingDelete = false 51 @State private var isConfirmingLeave = false 52 @State private var isConfirmingReveal = false 53 @State private var pendingRevealScope: RevealScope = .square 54 @State private var leaveError: String? 55 @State private var destructiveActionError: String? 56 @State private var isShowingShareSheet = false 57 @State private var hasSolved = false 58 @State private var replay = ReplayControls() 59 @State private var padLayout: PadLayout? 60 /// The shared open "arm" beat: flips a moment after open so the banner and 61 /// the "changed while you were away" borders reveal together. 62 @State private var isArmed = false 63 @Environment(\.engagementStatus) private var engagementStatus 64 65 private enum PadLayout { 66 case landscape 67 case portrait 68 } 69 70 private func swatchImage(for color: PlayerColor) -> Image { 71 let tint = UIColor(color.tint) 72 let base = UIImage(systemName: "circle.fill") ?? UIImage() 73 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 74 } 75 76 private struct TitleParts { 77 let title: String 78 let subtitle: String? 79 } 80 81 private var titleParts: TitleParts { 82 let title = session.puzzle.title 83 let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) 84 let subtitle: String? 85 if let publisher = session.puzzle.publisher, let formattedDate { 86 subtitle = "\(publisher) · \(formattedDate)" 87 } else if let publisher = session.puzzle.publisher { 88 subtitle = publisher 89 } else { 90 subtitle = formattedDate 91 } 92 return TitleParts(title: title, subtitle: subtitle) 93 } 94 95 // Latched completion counts as solved for the read-only presentation 96 // (hides the keyboard, shows the finish panel, disables the controls) even 97 // when the locally merged grid drifted and no longer reads `.solved`. 98 private var isSolved: Bool { hasSolved || session.mutator.isCompleted } 99 100 /// Whether a sticky, input-blocking announcement (currently only 101 /// access revocation) is showing for this game. Greys out the custom 102 /// keyboard and makes the hardware-key handler a no-op. 103 private var isInputBlocked: Bool { 104 announcements.isInputBlocked(forGame: session.mutator.gameID) 105 } 106 107 var body: some View { 108 Group { 109 switch padLayout { 110 case .landscape: 111 landscapePadLayout 112 case .portrait: 113 portraitPadLayout 114 case .none: 115 phoneLayout 116 } 117 } 118 .background(Color(.systemBackground)) 119 .background { 120 HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent) 121 .frame(width: 0, height: 0) 122 .allowsHitTesting(false) 123 } 124 .ignoresSafeArea(.keyboard) 125 .modifier(PuzzleToolbarModifier( 126 session: session, 127 roster: roster, 128 shareController: shareController, 129 isSolved: isSolved, 130 canResign: onResign != nil, 131 canDelete: onDelete != nil, 132 isRenaming: $isRenaming, 133 renameDraft: $renameDraft, 134 isConfirmingResign: $isConfirmingResign, 135 isConfirmingDelete: $isConfirmingDelete, 136 isConfirmingLeave: $isConfirmingLeave, 137 isConfirmingReveal: $isConfirmingReveal, 138 pendingRevealScope: $pendingRevealScope, 139 isShowingShareSheet: $isShowingShareSheet 140 )) 141 .modifier(PuzzleLifecycleModifier( 142 session: session, 143 roster: roster, 144 hasSolved: $hasSolved, 145 onCompletionEvent: handleCompletionEvent, 146 onSolvedOnAppear: { 147 onComplete?(false) 148 } 149 )) 150 .modifier(PuzzlePresentationModifier( 151 session: session, 152 shareController: shareController, 153 isRenaming: $isRenaming, 154 renameDraft: $renameDraft, 155 showErrorsAlert: $showErrorsAlert, 156 isConfirmingResign: $isConfirmingResign, 157 isConfirmingDelete: $isConfirmingDelete, 158 isConfirmingLeave: $isConfirmingLeave, 159 isConfirmingReveal: $isConfirmingReveal, 160 pendingRevealScope: $pendingRevealScope, 161 leaveError: $leaveError, 162 destructiveActionError: $destructiveActionError, 163 isShowingShareSheet: $isShowingShareSheet, 164 performResign: performResign, 165 performDelete: performDelete, 166 leaveSharedGame: leaveSharedGame 167 )) 168 .onGeometryChange(for: CGSize.self) { proxy in 169 proxy.size 170 } action: { newSize in 171 updateLayoutTrait(for: newSize) 172 } 173 .onAppear { 174 session.onRecentChangesAcknowledged = markPuzzleViewed 175 } 176 .task(id: session.mutator.gameID) { 177 // The shared open beat. A short hold lets the puzzle settle and the 178 // on-open sync land; then we arm the banner and capture — once — 179 // which cells a peer changed while we were away, so both reveal 180 // together. Moves that arrive after this are live activity (peer 181 // cursor tints), not part of the away-summary. 182 isArmed = false 183 try? await Task.sleep(for: .milliseconds(750)) 184 isArmed = true 185 if session.mutator.isShared { 186 session.recentChanges = loadRecentChanges() 187 } 188 } 189 } 190 191 private var phoneLayout: some View { 192 VStack(spacing: 0) { 193 puzzleArea 194 controlsArea(showClueBar: true) 195 } 196 } 197 198 private var landscapePadLayout: some View { 199 VStack(spacing: 0) { 200 HStack(spacing: 0) { 201 VStack(spacing: 0) { 202 if !isSolved { 203 PuzzleScoreboard(session: session, roster: roster) 204 205 Divider() 206 } 207 208 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 209 } 210 .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) 211 .background(Color(.secondarySystemBackground)) 212 213 Divider() 214 .ignoresSafeArea(edges: .top) 215 216 puzzleArea 217 .padding(.bottom, 12) 218 .frame(maxWidth: .infinity, maxHeight: .infinity) 219 } 220 .frame(maxWidth: .infinity, maxHeight: .infinity) 221 222 controlsArea(showClueBar: false) 223 } 224 } 225 226 private var portraitPadLayout: some View { 227 VStack(spacing: 0) { 228 WeightedVStack(weights: [3, 1]) { 229 puzzleArea 230 .frame(maxWidth: .infinity, maxHeight: .infinity) 231 .padding(.bottom, 12) 232 233 VStack(spacing: 0) { 234 Divider() 235 236 HStack(alignment: .top, spacing: 0) { 237 if !isSolved { 238 PuzzleScoreboard(session: session, roster: roster) 239 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) 240 241 Divider() 242 } 243 244 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 245 .frame(maxWidth: .infinity, maxHeight: .infinity) 246 } 247 .background(Color(.secondarySystemBackground)) 248 } 249 } 250 .frame(maxWidth: .infinity, maxHeight: .infinity) 251 252 controlsArea(showClueBar: false) 253 } 254 } 255 256 private func updateLayoutTrait(for size: CGSize) { 257 guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { 258 padLayout = nil 259 return 260 } 261 padLayout = size.width > size.height ? .landscape : .portrait 262 } 263 264 private func performResign() { 265 do { 266 try onResign?() 267 dismiss() 268 } catch { 269 destructiveActionError = String(describing: error) 270 } 271 } 272 273 private func performDelete() { 274 do { 275 try onDelete?() 276 dismiss() 277 } catch { 278 destructiveActionError = String(describing: error) 279 } 280 } 281 282 private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { 283 switch (event.origin, event.state) { 284 case (_, .incomplete): 285 break 286 case (.observed, .filledWithErrors): 287 // A collaborator's wrong entry must not interrupt the local solver. 288 break 289 case (.local, .filledWithErrors): 290 showErrorsAlert = true 291 case (.local, .solved): 292 guard !hasSolved else { return } 293 hasSolved = true 294 if session.isPencilMode { 295 session.togglePencil() 296 } 297 Task { @MainActor in 298 onComplete?(true) 299 } 300 case (.observed, .solved): 301 guard !hasSolved else { return } 302 hasSolved = true 303 onComplete?(false) 304 } 305 } 306 307 private var puzzleArea: some View { 308 ZStack { 309 VStack(spacing: 4) { 310 PuzzleHeader( 311 session: session, 312 roster: roster, 313 title: titleParts.title, 314 subtitle: titleParts.subtitle, 315 showsScoreboard: padLayout == nil, 316 gameID: session.mutator.gameID, 317 isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, 318 isArmed: isArmed 319 ) 320 GridView( 321 session: session, 322 roster: roster, 323 showsSharedAnnotations: session.mutator.isShared, 324 showsPeerCursors: !isSolved, 325 replayFrame: replay.frame 326 ) 327 } 328 .frame(maxWidth: .infinity, maxHeight: .infinity) 329 .padding(.top, -8) 330 331 if session.isRebusActive { 332 Color.black.opacity(0.35) 333 .ignoresSafeArea(edges: .top) 334 .contentShape(Rectangle()) 335 .onTapGesture { 336 session.commitRebus() 337 } 338 RebusModal(text: session.rebusBuffer) 339 .padding(.horizontal) 340 .contentShape(Rectangle()) 341 .onTapGesture { /* swallow */ } 342 } 343 } 344 } 345 346 private func controlsArea(showClueBar: Bool) -> some View { 347 VStack(spacing: 0) { 348 if showClueBar { 349 ClueBarSlot(session: session, replayFrame: replay.frame) 350 } 351 controlsPanel 352 .frame(height: controlsPanelHeight) 353 } 354 } 355 356 private var controlsPanel: some View { 357 ZStack(alignment: .top) { 358 if isSolved { 359 ControlsView(height: controlsPanelHeight) { 360 SuccessPanel( 361 session: session, 362 roster: roster, 363 replay: replay, 364 loadReplay: loadReplay 365 ) 366 } 367 .transition(.move(edge: .bottom)) 368 } else if showsCustomKeyboard { 369 ControlsView(height: controlsPanelHeight) { 370 KeyboardView(session: session, showsNavigationKeys: padLayout != nil) 371 .opacity(isInputBlocked ? 0.4 : 1) 372 .allowsHitTesting(!isInputBlocked) 373 .animation(.easeInOut(duration: 0.3), value: isInputBlocked) 374 } 375 .transition(.move(edge: .bottom)) 376 } 377 } 378 .frame(height: controlsPanelHeight, alignment: .top) 379 .background { 380 Color(.systemGroupedBackground) 381 .ignoresSafeArea(edges: .bottom) 382 } 383 .overlay(alignment: .top) { 384 if controlsPanelHeight > 0 { 385 Rectangle() 386 .fill(Color(.opaqueSeparator)) 387 .frame(height: 0.5) 388 } 389 } 390 .animation(.easeOut(duration: 0.25), value: isSolved) 391 .ignoresSafeArea(edges: .bottom) 392 } 393 394 private var controlsPanelHeight: CGFloat { 395 isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 396 } 397 398 private var showsCustomKeyboard: Bool { 399 !inputMonitor.isConnected 400 } 401 402 private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { 403 guard !isSolved, !isInputBlocked else { return false } 404 405 // Cmd+Z undoes, Shift-Cmd-Z redoes. Caught before the letter switch so 406 // the modified press isn't read as typing a "Z". 407 if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) { 408 guard !session.isRebusActive else { return false } 409 if event.modifierFlags.contains(.shift) { 410 session.redo() 411 } else { 412 session.undo() 413 } 414 return true 415 } 416 417 switch event.keyCode { 418 case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, 419 .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, 420 .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, 421 .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, 422 .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, 423 .keyboardZ: 424 guard !event.modifierFlags.contains(.command), 425 !event.modifierFlags.contains(.control), 426 !event.modifierFlags.contains(.alternate), 427 let letter = hardwareKeyboardLetter(from: event) else { 428 return false 429 } 430 if session.isRebusActive { 431 session.appendRebusLetter(letter) 432 } else { 433 session.enter(letter) 434 } 435 return true 436 437 case .keyboardDeleteOrBackspace, .keyboardDeleteForward: 438 if session.isRebusActive { 439 session.deleteRebusLetter() 440 } else { 441 session.deleteBackward() 442 } 443 return true 444 445 case .keyboardLeftArrow: 446 guard !session.isRebusActive else { return false } 447 if event.modifierFlags.contains(.command) { 448 session.goToPreviousWord() 449 return true 450 } 451 moveWithHardwareArrow(direction: .across) { 452 session.goToPreviousLetter() 453 } 454 return true 455 456 case .keyboardRightArrow: 457 guard !session.isRebusActive else { return false } 458 if event.modifierFlags.contains(.command) { 459 session.goToNextWord() 460 return true 461 } 462 moveWithHardwareArrow(direction: .across) { 463 session.goToNextLetter() 464 } 465 return true 466 467 case .keyboardUpArrow: 468 guard !session.isRebusActive else { return false } 469 moveWithHardwareArrow(direction: .down) { 470 session.goToPreviousLetter() 471 } 472 return true 473 474 case .keyboardDownArrow: 475 guard !session.isRebusActive else { return false } 476 moveWithHardwareArrow(direction: .down) { 477 session.goToNextLetter() 478 } 479 return true 480 481 case .keyboardTab: 482 guard !session.isRebusActive else { return false } 483 if event.modifierFlags.contains(.shift) { 484 session.goToPreviousClue() 485 } else { 486 session.goToNextClue() 487 } 488 return true 489 490 case .keyboardSpacebar: 491 guard !session.isRebusActive else { return false } 492 session.toggleDirection() 493 return true 494 495 case .keyboardReturnOrEnter: 496 if session.isRebusActive { 497 session.commitRebus() 498 } else { 499 session.toggleDirection() 500 } 501 return true 502 503 case .keyboardEscape: 504 if session.isRebusActive { 505 session.commitRebus() 506 return true 507 } 508 return false 509 510 default: 511 return false 512 } 513 } 514 515 private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? { 516 let scalars = event.charactersIgnoringModifiers.unicodeScalars 517 guard scalars.count == 1, let scalar = scalars.first else { return nil } 518 519 switch scalar.value { 520 case 65...90, 97...122: 521 return String(Character(scalar)).uppercased() 522 default: 523 return nil 524 } 525 } 526 527 private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { 528 if session.direction != direction { 529 let previousDirection = session.direction 530 session.setDirection(direction) 531 if session.direction != previousDirection { 532 return 533 } 534 } 535 536 move() 537 } 538 539 private func leaveSharedGame() async { 540 guard let shareController else { return } 541 do { 542 try await shareController.leaveShare(gameID: session.mutator.gameID) 543 dismiss() 544 } catch { 545 leaveError = String(describing: error) 546 } 547 } 548 } 549 550 private struct PuzzleScoreboard: View { 551 @Bindable var session: PlayerSession 552 let roster: PlayerRoster 553 var layout: Layout = .vertical 554 @Environment(PlayerPreferences.self) private var preferences 555 @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 556 557 enum Layout { 558 /// Side-panel style: stacked rows under a "Players" heading. 559 case vertical 560 /// Paged-header style: a horizontally scrollable strip of player 561 /// chips, sized to scroll past two players when more arrive. 562 case horizontal 563 } 564 565 private struct Score: Identifiable { 566 let authorID: String? 567 let name: String 568 let color: PlayerColor? 569 let filledCount: Int 570 571 var id: String { authorID ?? "unattributed" } 572 } 573 574 private var fillableCellCount: Int { 575 session.puzzle.cells.reduce(0) { count, row in 576 count + row.filter { !$0.isBlock }.count 577 } 578 } 579 580 private var filledCellCount: Int { 581 var count = 0 582 for r in 0..<session.puzzle.height { 583 for c in 0..<session.puzzle.width { 584 guard !session.puzzle.cells[r][c].isBlock else { continue } 585 if !session.game.squares[r][c].entry.isEmpty { 586 count += 1 587 } 588 } 589 } 590 return count 591 } 592 593 private var revealedSquareCount: Int { 594 var count = 0 595 for r in 0..<session.puzzle.height { 596 for c in 0..<session.puzzle.width { 597 guard !session.puzzle.cells[r][c].isBlock else { continue } 598 if session.game.squares[r][c].mark.isRevealed { 599 count += 1 600 } 601 } 602 } 603 return count 604 } 605 606 private var remainingCount: Int { 607 max(0, fillableCellCount - filledCellCount) 608 } 609 610 private var remainingPhrase: String { 611 switch remainingCount { 612 case 0: 613 return "no squares to go" 614 case 1: 615 return "1 square to go" 616 default: 617 return "\(remainingCount) squares to go" 618 } 619 } 620 621 private var revealedPhrase: String { 622 switch revealedSquareCount { 623 case 0: 624 return "No squares revealed" 625 case 1: 626 return "1 square revealed" 627 default: 628 return "\(revealedSquareCount) squares revealed" 629 } 630 } 631 632 private var progressText: String { 633 if revealedSquareCount > 0 { 634 return "\(revealedPhrase), \(remainingPhrase)" 635 } 636 switch remainingCount { 637 case 0: 638 return "No squares to go" 639 case 1: 640 return "1 square to go" 641 default: 642 return "\(remainingCount) squares to go" 643 } 644 } 645 646 private var scores: [Score] { 647 var counts: [String?: Int] = [:] 648 for r in 0..<session.puzzle.height { 649 for c in 0..<session.puzzle.width { 650 guard !session.puzzle.cells[r][c].isBlock else { continue } 651 let square = session.game.squares[r][c] 652 guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } 653 counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 654 } 655 } 656 657 let entries = roster.entries 658 let usesLocalFallback = entries.isEmpty 659 let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) 660 let rosterAuthorIDs = Set(entries.map(\.authorID)) 661 662 let rosterScores: [Score] 663 if usesLocalFallback { 664 rosterScores = [ 665 Score( 666 authorID: nil, 667 name: preferences.name, 668 color: preferences.color, 669 filledCount: counts[nil] ?? 0 670 ) 671 ] 672 } else { 673 rosterScores = entries.map { entry in 674 Score( 675 authorID: entry.authorID, 676 name: entry.name, 677 color: entry.color, 678 filledCount: counts[entry.authorID] ?? 0 679 ) 680 } 681 } 682 683 let extraScores = counts.compactMap { authorID, count -> Score? in 684 if let authorID, rosterAuthorIDs.contains(authorID) { 685 return nil 686 } 687 if authorID == nil && usesLocalFallback { 688 return nil 689 } 690 if let authorID, let entry = entryByAuthorID[authorID] { 691 return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) 692 } 693 if authorID == nil { 694 // A `nil` author key only arises with remote players present 695 // (see `normalizedAuthorID`): an authorless square, e.g. a cell 696 // sealed to the solution at completion before its author's 697 // letter arrived. It belongs to no player, so drop it rather 698 // than tallying an "Unattributed" entry. 699 return nil 700 } 701 return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) 702 } 703 704 return (rosterScores + extraScores) 705 .sorted { 706 if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } 707 return $0.name < $1.name 708 } 709 } 710 711 private func normalizedAuthorID(_ authorID: String?) -> String? { 712 guard let authorID else { 713 return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID 714 } 715 return authorID 716 } 717 718 @ViewBuilder 719 var body: some View { 720 switch layout { 721 case .vertical: 722 verticalBody 723 case .horizontal: 724 horizontalBody 725 } 726 } 727 728 private var verticalBody: some View { 729 VStack(alignment: .leading, spacing: 12) { 730 Text("Players") 731 .font(.headline) 732 733 VStack(alignment: .leading, spacing: 6) { 734 ForEach(scores) { score in 735 scoreRow(score) 736 } 737 738 Text(progressText) 739 .font(.footnote) 740 .foregroundStyle(.secondary) 741 .padding(.top, 10) 742 .frame(maxWidth: .infinity, alignment: .center) 743 } 744 } 745 .padding(.horizontal, 18) 746 .padding(.vertical, 14) 747 .frame(maxWidth: .infinity, alignment: .leading) 748 } 749 750 private var chipFlow: some View { 751 FlowLayout(spacing: 18, lineSpacing: 8) { 752 ForEach(scores) { score in 753 scoreChip(score) 754 } 755 } 756 .padding(.horizontal, 18) 757 .padding(.vertical, 4) 758 } 759 760 private var horizontalBody: some View { 761 // A titled "Players" section mirroring the iPad side panel 762 // (verticalBody). It sizes to its content and sits top-anchored 763 // in a ScrollView, so it reads as a deliberate header section 764 // rather than a stray chip, and scrolls when there are enough 765 // players to overflow the band — no centring tricks required. 766 ScrollView(.vertical, showsIndicators: false) { 767 VStack(spacing: 6) { 768 Text("Players") 769 .font(.subheadline.weight(.semibold)) 770 chipFlow 771 } 772 .frame(maxWidth: .infinity) 773 .padding(.vertical, 8) 774 } 775 .frame(height: horizontalHeaderHeight) 776 } 777 778 private func scoreChip(_ score: Score) -> some View { 779 HStack(spacing: 6) { 780 Circle() 781 .fill(score.color?.tint ?? Color.secondary) 782 .frame(width: 8, height: 8) 783 Text(score.name) 784 .font(.subheadline) 785 .lineLimit(1) 786 Text("\(score.filledCount)") 787 .font(.subheadline.monospacedDigit().weight(.semibold)) 788 } 789 .accessibilityElement(children: .combine) 790 } 791 792 private func scoreRow(_ score: Score) -> some View { 793 HStack(spacing: 8) { 794 Circle() 795 .fill(score.color?.tint ?? Color.secondary) 796 .frame(width: 8, height: 8) 797 Text(score.name) 798 .font(.subheadline) 799 .lineLimit(1) 800 Spacer(minLength: 8) 801 Text("\(score.filledCount)") 802 .font(.subheadline.monospacedDigit().weight(.semibold)) 803 } 804 .accessibilityElement(children: .combine) 805 } 806 } 807 808 private struct PuzzleToolbarModifier: ViewModifier { 809 let session: PlayerSession 810 let roster: PlayerRoster 811 let shareController: ShareController? 812 let isSolved: Bool 813 let canResign: Bool 814 let canDelete: Bool 815 @Binding var isRenaming: Bool 816 @Binding var renameDraft: String 817 @Binding var isConfirmingResign: Bool 818 @Binding var isConfirmingDelete: Bool 819 @Binding var isConfirmingLeave: Bool 820 @Binding var isConfirmingReveal: Bool 821 @Binding var pendingRevealScope: RevealScope 822 @Binding var isShowingShareSheet: Bool 823 @Environment(PlayerPreferences.self) private var preferences 824 @AppStorage("debugMode") private var debugMode = false 825 826 func body(content: Content) -> some View { 827 content.toolbar { 828 ToolbarItemGroup(placement: .topBarTrailing) { 829 pencilButton 830 entryMenu 831 hintsMenu 832 playersMenu 833 } 834 } 835 } 836 837 private func swatchImage(for color: PlayerColor) -> Image { 838 let tint = UIColor(color.tint) 839 let base = UIImage(systemName: "circle.fill") ?? UIImage() 840 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 841 } 842 843 private var pencilButton: some View { 844 Button { 845 session.togglePencil() 846 } label: { 847 Image(systemName: "pencil") 848 .foregroundStyle(pencilButtonForeground) 849 .padding(6) 850 .glassEffect( 851 !isSolved && session.isPencilMode 852 ? .regular.tint(preferences.color.tint) 853 : .identity, 854 in: Circle() 855 ) 856 } 857 .accessibilityLabel("Pencil") 858 .disabled(isSolved) 859 } 860 861 private var pencilButtonForeground: Color { 862 if isSolved { 863 return .secondary 864 } 865 return session.isPencilMode ? .white : .primary 866 } 867 868 private var entryMenu: some View { 869 Menu { 870 Section { 871 Button("Undo Move") { session.undo() } 872 .disabled(!session.canUndo) 873 Button("Redo Move") { session.redo() } 874 .disabled(!session.canRedo) 875 } 876 877 Section { 878 Button("Enter Rebus") { session.startRebus() } 879 Button("Toggle Direction") { session.toggleDirection() } 880 } 881 882 if debugMode { 883 Section { 884 NavigationLink { 885 DiagnosticsView() 886 } label: { 887 Text("Diagnostics Log") 888 } 889 } 890 } 891 892 Section { 893 Button("Clear Word") { session.clearCurrentWord() } 894 Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } 895 } 896 } label: { 897 Label("Entry", systemImage: "squareshape.split.2x2") 898 } 899 .disabled(isSolved) 900 } 901 902 private var hintsMenu: some View { 903 Menu { 904 Section { 905 Button("Check Square") { session.checkSquare() } 906 Button("Check Word") { session.checkCurrentWord() } 907 Button("Check Puzzle") { session.checkPuzzle() } 908 } 909 Section { 910 Button("Reveal Square") { confirmReveal(.square) } 911 Button("Reveal Word") { confirmReveal(.word) } 912 Button("Reveal Puzzle") { confirmReveal(.puzzle) } 913 } 914 } label: { 915 Label("Hints", systemImage: "lightbulb") 916 } 917 .disabled(isSolved) 918 } 919 920 private func confirmReveal(_ scope: RevealScope) { 921 pendingRevealScope = scope 922 isConfirmingReveal = true 923 } 924 925 private var playersMenu: some View { 926 Menu { 927 playerRosterSection 928 playerPreferencesSection 929 shareSection 930 puzzleDestructiveSection 931 } label: { 932 Label("Players", systemImage: "person.2") 933 } 934 .disabled(isSolved) 935 } 936 937 @ViewBuilder 938 private var playerRosterSection: some View { 939 Section { 940 if !roster.entries.isEmpty { 941 ForEach(roster.entries) { entry in 942 Button {} label: { 943 Label { 944 Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) 945 } icon: { 946 swatchImage(for: entry.color) 947 } 948 } 949 .disabled(true) 950 } 951 } else { 952 Button {} label: { 953 Label { 954 Text(preferences.name) 955 } icon: { 956 swatchImage(for: preferences.color) 957 } 958 } 959 .disabled(true) 960 } 961 } 962 } 963 964 private var playerPreferencesSection: some View { 965 Section { 966 Menu("Change Colour") { 967 ForEach(PlayerColor.palette) { color in 968 Button { 969 preferences.color = color 970 // Friend colours are derived with the local user's 971 // colour reserved, so refreshing re-derives and bumps 972 // any friend that now collides with the new choice. 973 Task { await roster.refresh() } 974 } label: { 975 Label { 976 Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) 977 } icon: { 978 swatchImage(for: color) 979 } 980 } 981 } 982 } 983 984 Button("Change Name") { 985 renameDraft = preferences.name 986 isRenaming = true 987 } 988 } 989 } 990 991 @ViewBuilder 992 private var shareSection: some View { 993 if shareController != nil { 994 Section { 995 Button { 996 isShowingShareSheet = true 997 } label: { 998 Text("Share Game") 999 } 1000 .disabled(!session.mutator.isOwned) 1001 } 1002 } 1003 } 1004 1005 private var puzzleDestructiveSection: some View { 1006 Section { 1007 Button("Resign Game", role: .destructive) { 1008 isConfirmingResign = true 1009 } 1010 .disabled(isSolved || !canResign) 1011 1012 if session.mutator.isShared && !session.mutator.isOwned { 1013 Button("Leave Game", role: .destructive) { 1014 isConfirmingLeave = true 1015 } 1016 .disabled(shareController == nil) 1017 } else { 1018 Button("Delete Game", role: .destructive) { 1019 isConfirmingDelete = true 1020 } 1021 .disabled(!canDelete) 1022 } 1023 } 1024 } 1025 } 1026 1027 private struct PuzzleLifecycleModifier: ViewModifier { 1028 let session: PlayerSession 1029 let roster: PlayerRoster 1030 @Binding var hasSolved: Bool 1031 let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void 1032 let onSolvedOnAppear: () -> Void 1033 1034 func body(content: Content) -> some View { 1035 content 1036 .task { 1037 await roster.refresh() 1038 } 1039 .onAppear { 1040 if session.game.completionState == .solved { 1041 hasSolved = true 1042 onSolvedOnAppear() 1043 } 1044 } 1045 .onChange(of: session.completionEvent) { _, newValue in 1046 guard let newValue else { return } 1047 onCompletionEvent(newValue) 1048 } 1049 } 1050 } 1051 1052 private struct PuzzlePresentationModifier: ViewModifier { 1053 let session: PlayerSession 1054 let shareController: ShareController? 1055 @Binding var isRenaming: Bool 1056 @Binding var renameDraft: String 1057 @Binding var showErrorsAlert: Bool 1058 @Binding var isConfirmingResign: Bool 1059 @Binding var isConfirmingDelete: Bool 1060 @Binding var isConfirmingLeave: Bool 1061 @Binding var isConfirmingReveal: Bool 1062 @Binding var pendingRevealScope: RevealScope 1063 @Binding var leaveError: String? 1064 @Binding var destructiveActionError: String? 1065 @Binding var isShowingShareSheet: Bool 1066 let performResign: () -> Void 1067 let performDelete: () -> Void 1068 let leaveSharedGame: () async -> Void 1069 @Environment(PlayerPreferences.self) private var preferences 1070 1071 func body(content: Content) -> some View { 1072 content 1073 .alert("Not Quite Right", isPresented: $showErrorsAlert) { 1074 Button("OK", role: .cancel) {} 1075 } message: { 1076 Text("One or more squares are incorrect.") 1077 } 1078 .alert("Resign Puzzle?", isPresented: $isConfirmingResign) { 1079 Button("Resign", role: .destructive) { 1080 performResign() 1081 } 1082 Button("Cancel", role: .cancel) {} 1083 } message: { 1084 Text("This will reveal the puzzle and mark it complete.") 1085 } 1086 .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) { 1087 Button("Delete", role: .destructive) { 1088 performDelete() 1089 } 1090 Button("Cancel", role: .cancel) {} 1091 } message: { 1092 deleteConfirmationMessage 1093 } 1094 .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { 1095 Button("Leave", role: .destructive) { 1096 Task { await leaveSharedGame() } 1097 } 1098 Button("Cancel", role: .cancel) {} 1099 } message: { 1100 Text("You will lose access to \"\(session.puzzle.title)\".") 1101 } 1102 .alert(pendingRevealScope.title, isPresented: $isConfirmingReveal) { 1103 Button("Reveal", role: .destructive) { 1104 performReveal(pendingRevealScope) 1105 } 1106 Button("Cancel", role: .cancel) {} 1107 } message: { 1108 Text(pendingRevealScope.message) 1109 } 1110 .alert( 1111 "Couldn't Leave", 1112 isPresented: .init( 1113 get: { leaveError != nil }, 1114 set: { if !$0 { leaveError = nil } } 1115 ), 1116 presenting: leaveError 1117 ) { _ in 1118 Button("OK", role: .cancel) {} 1119 } message: { message in 1120 Text(message) 1121 } 1122 .alert( 1123 "Couldn't Update Puzzle", 1124 isPresented: .init( 1125 get: { destructiveActionError != nil }, 1126 set: { if !$0 { destructiveActionError = nil } } 1127 ), 1128 presenting: destructiveActionError 1129 ) { _ in 1130 Button("OK", role: .cancel) {} 1131 } message: { message in 1132 Text(message) 1133 } 1134 .alert("Change Name", isPresented: $isRenaming) { 1135 TextField("Name", text: $renameDraft) 1136 .textInputAutocapitalization(.never) 1137 .autocorrectionDisabled() 1138 Button("Cancel", role: .cancel) {} 1139 Button("Save") { 1140 let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) 1141 if !trimmed.isEmpty { 1142 preferences.name = trimmed 1143 } 1144 } 1145 .keyboardShortcut(.defaultAction) 1146 } message: { 1147 Text("Enter the name other players will see.") 1148 } 1149 .sheet(isPresented: $isShowingShareSheet) { 1150 if let shareController { 1151 GameShareSheet( 1152 gameID: session.mutator.gameID, 1153 title: session.puzzle.title, 1154 shareController: shareController 1155 ) 1156 } 1157 } 1158 } 1159 1160 private func performReveal(_ scope: RevealScope) { 1161 switch scope { 1162 case .square: session.revealSquare() 1163 case .word: session.revealCurrentWord() 1164 case .puzzle: session.revealPuzzle() 1165 } 1166 } 1167 1168 private var deleteConfirmationMessage: Text { 1169 if session.mutator.isOwned && session.mutator.isShared { 1170 Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.") 1171 } else { 1172 Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.") 1173 } 1174 } 1175 } 1176 1177 /// Swipeable header that sits above the grid. Page 1 is the title, the 1178 /// last page is the credits, and on iPhone a scoreboard page sits between 1179 /// them (iPad shows the scoreboard permanently in the side panel, so it is 1180 /// omitted here). A fixed height is required because `.page` style fills 1181 /// its container rather than sizing to content. 1182 private struct PuzzleHeader: View { 1183 @Bindable var session: PlayerSession 1184 let roster: PlayerRoster 1185 let title: String 1186 let subtitle: String? 1187 let showsScoreboard: Bool 1188 let gameID: UUID 1189 let isEngagementLive: Bool 1190 /// The shared open "arm" beat, owned by `PuzzleView` so the banner and the 1191 /// grid's "changed while you were away" borders reveal together. Until it 1192 /// flips (a moment after open), the title is the only thing on screen; 1193 /// then banner posts — including a session summary that arrived during the 1194 /// hold — animate in. 1195 let isArmed: Bool 1196 @Environment(AnnouncementCenter.self) private var announcements 1197 @Environment(\.dynamicTypeSize) private var dynamicTypeSize 1198 @State private var selection: Page = .title 1199 1200 private enum Page: Hashable { 1201 case title 1202 case scoreboard 1203 case credits 1204 } 1205 1206 /// Reconstructed as "© <year> <publisher>" from the puzzle's date and 1207 /// publisher, falling back to whatever pieces exist, and finally to the 1208 /// raw copyright string parsed from the source. 1209 private var copyrightLine: String? { 1210 let year = session.puzzle.date.map { 1211 Calendar.current.component(.year, from: $0) 1212 } 1213 switch (year, session.puzzle.publisher) { 1214 case let (year?, publisher?): 1215 return "© \(year) \(publisher)" 1216 case let (year?, nil): 1217 return "© \(year)" 1218 case let (nil, publisher?): 1219 return "© \(publisher)" 1220 case (nil, nil): 1221 return session.puzzle.copyright 1222 } 1223 } 1224 1225 private var hasCredits: Bool { 1226 session.puzzle.author != nil || copyrightLine != nil 1227 } 1228 1229 private var pages: [Page] { 1230 var result: [Page] = [.title] 1231 if showsScoreboard { result.append(.scoreboard) } 1232 if hasCredits { result.append(.credits) } 1233 return result 1234 } 1235 1236 /// Above the default text size the clue bar below the grid grows to fit 1237 /// the (must-read) clue, squeezing the grid. The title/scoreboard/credits 1238 /// shown here are the least important text on screen, so the header yields 1239 /// its own height as type scales up — shedding a few points per step down 1240 /// to a legible-enough floor — and hands that space back to the grid. The 1241 /// text inside just truncates within the smaller box. At or below the 1242 /// default size the comfortable full height is preserved. 1243 private var headerHeight: CGFloat { 1244 let sizes = DynamicTypeSize.allCases 1245 guard let current = sizes.firstIndex(of: dynamicTypeSize), 1246 let baseline = sizes.firstIndex(of: .large) 1247 else { return 80 } 1248 let stepsAboveDefault = max(0, current - baseline) 1249 return max(48, 80 - CGFloat(stepsAboveDefault) * 6) 1250 } 1251 1252 var body: some View { 1253 let visibleAnnouncement = isArmed 1254 ? announcements.current(forGame: gameID) 1255 : nil 1256 Group { 1257 // Title/scoreboard/credits is the baseline — it renders 1258 // immediately on open and stays put. After the open beat we 1259 // start reacting to announcements: the banner slides down 1260 // over the title and slides back out on dismissal. Both 1261 // branches occupy the same fixed-height frame so the grid 1262 // below doesn't jump. 1263 if let announcement = visibleAnnouncement { 1264 AnnouncementBanner( 1265 announcement: announcement, 1266 fillsAvailableHeight: true 1267 ) { 1268 announcements.dismiss(id: announcement.id) 1269 } 1270 .padding(.horizontal, 12) 1271 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 1272 .transition(.move(edge: .bottom).combined(with: .opacity)) 1273 } else { 1274 headerPages 1275 .transition(.move(edge: .bottom).combined(with: .opacity)) 1276 } 1277 } 1278 .frame(height: headerHeight) 1279 .padding(.bottom, 14) 1280 .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement) 1281 .animation(.easeInOut(duration: 0.2), value: isEngagementLive) 1282 } 1283 1284 private var headerPages: some View { 1285 VStack(spacing: 10) { 1286 TabView(selection: $selection) { 1287 ForEach(pages, id: \.self) { page in 1288 pageContent(page) 1289 .tag(page) 1290 } 1291 } 1292 .tabViewStyle(.page(indexDisplayMode: .never)) 1293 1294 if pages.count > 1 { 1295 HStack(spacing: 6) { 1296 ForEach(pages, id: \.self) { page in 1297 Circle() 1298 .fill(page == selection ? Color.secondary : Color.secondary.opacity(0.3)) 1299 .frame(width: 6, height: 6) 1300 } 1301 } 1302 .animation(.easeInOut(duration: 0.2), value: selection) 1303 } 1304 } 1305 } 1306 1307 @ViewBuilder 1308 private func pageContent(_ page: Page) -> some View { 1309 switch page { 1310 case .title: 1311 PuzzleTitle(title: title, subtitle: subtitle, isEngagementLive: isEngagementLive) 1312 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 1313 case .scoreboard: 1314 PuzzleScoreboard(session: session, roster: roster, layout: .horizontal) 1315 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 1316 case .credits: 1317 PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine) 1318 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 1319 } 1320 } 1321 } 1322 1323 private struct PuzzleTitle: View { 1324 let title: String 1325 let subtitle: String? 1326 let isEngagementLive: Bool 1327 @State private var showsEngagementIcon = false 1328 1329 var body: some View { 1330 VStack(spacing: 2) { 1331 Text(title) 1332 .font(.headline) 1333 .lineLimit(2) 1334 .overlay(alignment: .trailing) { 1335 engagementIcon 1336 .offset(x: 28) 1337 .opacity(showsEngagementIcon ? 1 : 0) 1338 .accessibilityLabel("Engagement live") 1339 .accessibilityHidden(!showsEngagementIcon) 1340 } 1341 if let subtitle { 1342 Text(subtitle) 1343 .font(.subheadline) 1344 .foregroundStyle(.secondary) 1345 .lineLimit(1) 1346 } 1347 } 1348 .multilineTextAlignment(.center) 1349 .frame(maxWidth: .infinity) 1350 .padding(.horizontal) 1351 .animation(.easeInOut(duration: 0.2), value: showsEngagementIcon) 1352 .onAppear { 1353 showsEngagementIcon = isEngagementLive 1354 } 1355 .onChange(of: isEngagementLive) { _, isLive in 1356 withAnimation(.easeInOut(duration: 0.2)) { 1357 showsEngagementIcon = isLive 1358 } 1359 } 1360 } 1361 1362 private var engagementIcon: some View { 1363 Image(systemName: "bolt.circle") 1364 .font(.headline) 1365 .foregroundStyle(.green) 1366 .symbolRenderingMode(.monochrome) 1367 } 1368 } 1369 1370 private struct PuzzleCredits: View { 1371 let author: String? 1372 let copyright: String? 1373 1374 var body: some View { 1375 VStack(spacing: 2) { 1376 if let author, !author.isEmpty { 1377 Text("By \(author)") 1378 .font(.subheadline) 1379 .lineLimit(2) 1380 } 1381 if let copyright { 1382 Text(copyright) 1383 .font(.footnote) 1384 .foregroundStyle(.secondary) 1385 .lineLimit(2) 1386 } 1387 } 1388 .multilineTextAlignment(.center) 1389 .frame(maxWidth: .infinity) 1390 .padding(.horizontal) 1391 } 1392 } 1393 1394 private struct ClueKey: Hashable { 1395 let direction: Puzzle.Direction 1396 let number: Int 1397 } 1398 1399 private struct ReplayClueTarget { 1400 let position: GridPosition 1401 let direction: Puzzle.Direction? 1402 } 1403 1404 private struct ClueBarSlot: View { 1405 @Bindable var session: PlayerSession 1406 let replayFrame: ReplayFrame? 1407 1408 private var replayClueTarget: ReplayClueTarget? { 1409 guard let cursor = replayFrame?.cursor else { return nil } 1410 return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection) 1411 } 1412 1413 var body: some View { 1414 ZStack(alignment: .bottom) { 1415 ClueBarReservation() 1416 1417 ClueBar(session: session, replayClueTarget: replayClueTarget) 1418 } 1419 } 1420 } 1421 1422 private struct ClueBarReservation: View { 1423 var body: some View { 1424 ClueBarContent( 1425 label: "99 Across", 1426 clueText: "Clue reservation", 1427 reservesClueSpace: true 1428 ) 1429 .opacity(0) 1430 .accessibilityHidden(true) 1431 .allowsHitTesting(false) 1432 } 1433 } 1434 1435 private struct ClueBarContent: View { 1436 let label: String 1437 let clueText: String 1438 var reservesClueSpace = false 1439 var currentKey: ClueKey? 1440 var slideEdge: Edge = .trailing 1441 var onPrevious: (() -> Void)? 1442 var onNext: (() -> Void)? 1443 var onClueTap: (() -> Void)? 1444 var onLabelTap: (() -> Void)? 1445 1446 var body: some View { 1447 HStack(alignment: .clueCenter, spacing: 8) { 1448 ClueBarIcon(systemName: "chevron.left", action: onPrevious) 1449 1450 VStack(alignment: .leading, spacing: 4) { 1451 Text(label) 1452 .font(.caption) 1453 .textCase(.uppercase) 1454 .foregroundStyle(.secondary) 1455 .contentShape(Rectangle()) 1456 .highPriorityGesture( 1457 TapGesture() 1458 .onEnded { 1459 onLabelTap?() 1460 } 1461 ) 1462 ZStack(alignment: .leading) { 1463 clueTextView 1464 } 1465 .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] } 1466 .frame(maxWidth: .infinity, alignment: .leading) 1467 .clipped() 1468 } 1469 .contentShape(Rectangle()) 1470 .onTapGesture { 1471 onClueTap?() 1472 } 1473 1474 ClueBarIcon(systemName: "chevron.right", action: onNext) 1475 } 1476 .padding(.horizontal, 8) 1477 .padding(.top, 12) 1478 .padding(.bottom, 6) 1479 } 1480 1481 @ViewBuilder 1482 private var clueTextView: some View { 1483 baseClueText 1484 .id(currentKey) 1485 .transition(.asymmetric( 1486 insertion: .move(edge: slideEdge), 1487 removal: .move(edge: slideEdge == .trailing ? .leading : .trailing) 1488 )) 1489 } 1490 1491 private var baseClueText: some View { 1492 Text(clueText) 1493 .font(.headline) 1494 .lineLimit(2, reservesSpace: reservesClueSpace) 1495 .multilineTextAlignment(.leading) 1496 .frame(maxWidth: .infinity, alignment: .leading) 1497 } 1498 } 1499 1500 private struct ClueBarIcon: View { 1501 let systemName: String 1502 var action: (() -> Void)? 1503 1504 var body: some View { 1505 if let action { 1506 Button(action: action) { 1507 icon 1508 } 1509 .buttonStyle(.plain) 1510 } else { 1511 icon 1512 } 1513 } 1514 1515 private var icon: some View { 1516 Image(systemName: systemName) 1517 .font(.title3.weight(.semibold)) 1518 .frame(width: 44, height: 44) 1519 .contentShape(Rectangle()) 1520 } 1521 } 1522 1523 private struct ClueBar: View { 1524 @Bindable var session: PlayerSession 1525 let replayClueTarget: ReplayClueTarget? 1526 @Environment(PlayerPreferences.self) private var preferences 1527 @Environment(\.colorScheme) private var colorScheme 1528 @State private var slideEdge: Edge = .trailing 1529 @State private var isShowingClueList = false 1530 1531 private var backgroundColor: Color { 1532 preferences.color.clueBarFill(dark: colorScheme == .dark) 1533 } 1534 1535 var body: some View { 1536 let display = replayClueDisplay ?? liveClueDisplay 1537 let isShowingReplayClue = replayClueDisplay != nil 1538 1539 ClueBarContent( 1540 label: label(for: display.clue, direction: display.direction), 1541 clueText: display.clue?.text ?? "—", 1542 currentKey: display.currentKey, 1543 slideEdge: slideEdge, 1544 onPrevious: isShowingReplayClue ? nil : { 1545 slideEdge = .leading 1546 session.goToPreviousClue() 1547 }, 1548 onNext: isShowingReplayClue ? nil : { 1549 slideEdge = .trailing 1550 session.goToNextClue() 1551 }, 1552 onClueTap: isShowingReplayClue ? nil : { 1553 isShowingClueList = true 1554 }, 1555 onLabelTap: isShowingReplayClue ? nil : { 1556 session.toggleDirection() 1557 } 1558 ) 1559 .background(backgroundColor) 1560 .animation( 1561 isShowingReplayClue ? nil : .smooth(duration: 0.22), 1562 value: display.currentKey 1563 ) 1564 .sheet(isPresented: $isShowingClueList) { 1565 ClueList(session: session) 1566 .presentationDetents([.medium, .large]) 1567 .presentationDragIndicator(.visible) 1568 } 1569 } 1570 1571 private var liveClueDisplay: ClueDisplay { 1572 let clue = session.currentClue() 1573 return ClueDisplay(clue: clue, direction: session.direction) 1574 } 1575 1576 private var replayClueDisplay: ClueDisplay? { 1577 guard let replayClueTarget else { return nil } 1578 let position = replayClueTarget.position 1579 guard let direction = replayClueTarget.direction else { return nil } 1580 return ClueDisplay( 1581 clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), 1582 direction: direction 1583 ) 1584 } 1585 1586 private struct ClueDisplay { 1587 let clue: Puzzle.Clue? 1588 let direction: Puzzle.Direction 1589 1590 var currentKey: ClueKey? { 1591 clue.map { ClueKey(direction: direction, number: $0.number) } 1592 } 1593 } 1594 1595 private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String { 1596 let direction = direction == .across ? "Across" : "Down" 1597 if let clue { 1598 return "\(clue.number) \(direction)" 1599 } 1600 return direction 1601 } 1602 } 1603 1604 private struct RebusModal: View { 1605 let text: String 1606 1607 var body: some View { 1608 Text(text.isEmpty ? " " : text) 1609 .font(.system(size: 32, weight: .semibold, design: .rounded)) 1610 .foregroundStyle(.primary) 1611 .frame(maxWidth: .infinity, minHeight: 56) 1612 .padding(.horizontal, 16) 1613 .background(Color(.systemBackground)) 1614 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 1615 .padding(20) 1616 .frame(maxWidth: .infinity) 1617 .background(Color(.secondarySystemBackground)) 1618 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) 1619 } 1620 } 1621 1622 private struct ControlsView<Content: View>: View { 1623 let height: CGFloat 1624 @ViewBuilder var content: () -> Content 1625 1626 var body: some View { 1627 VStack(spacing: 0) { 1628 content() 1629 .frame(height: height) 1630 Color(.systemGroupedBackground) 1631 } 1632 .background(Color(.systemGroupedBackground)) 1633 .ignoresSafeArea(edges: .bottom) 1634 } 1635 } 1636 1637 private extension VerticalAlignment { 1638 enum ClueCenterID: AlignmentID { 1639 static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] } 1640 } 1641 static let clueCenter = VerticalAlignment(ClueCenterID.self) 1642 } 1643 1644 /// Lays subviews left-to-right, wrapping onto a new line when the next 1645 /// subview would overflow the proposed width. Reports the wrapped 1646 /// height for that width so a surrounding `ViewThatFits` can choose 1647 /// between a centred (fits) and a scrolling (overflows) presentation. 1648 private struct FlowLayout: Layout { 1649 var spacing: CGFloat = 18 1650 var lineSpacing: CGFloat = 8 1651 1652 private struct Row { 1653 var indices: [Int] = [] 1654 var width: CGFloat = 0 1655 var height: CGFloat = 0 1656 } 1657 1658 private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] { 1659 var rows: [Row] = [] 1660 var current = Row() 1661 for index in subviews.indices { 1662 let size = subviews[index].sizeThatFits(.unspecified) 1663 let needed = current.indices.isEmpty 1664 ? size.width 1665 : current.width + spacing + size.width 1666 if !current.indices.isEmpty, needed > maxWidth { 1667 rows.append(current) 1668 current = Row(indices: [index], width: size.width, height: size.height) 1669 } else { 1670 if !current.indices.isEmpty { current.width += spacing } 1671 current.indices.append(index) 1672 current.width += size.width 1673 current.height = max(current.height, size.height) 1674 } 1675 } 1676 if !current.indices.isEmpty { rows.append(current) } 1677 return rows 1678 } 1679 1680 func sizeThatFits( 1681 proposal: ProposedViewSize, 1682 subviews: Subviews, 1683 cache: inout () 1684 ) -> CGSize { 1685 let rows = rows(subviews, maxWidth: proposal.width ?? .infinity) 1686 let height = rows.reduce(0) { $0 + $1.height } 1687 + lineSpacing * CGFloat(max(0, rows.count - 1)) 1688 let widest = rows.map(\.width).max() ?? 0 1689 return CGSize(width: proposal.width ?? widest, height: height) 1690 } 1691 1692 func placeSubviews( 1693 in bounds: CGRect, 1694 proposal: ProposedViewSize, 1695 subviews: Subviews, 1696 cache: inout () 1697 ) { 1698 let rows = rows(subviews, maxWidth: bounds.width) 1699 var y = bounds.minY 1700 for row in rows { 1701 // Centre each row within the available width so a short 1702 // strip (e.g. a single player) sits in the middle. 1703 var x = bounds.minX + max(0, (bounds.width - row.width) / 2) 1704 for index in row.indices { 1705 let size = subviews[index].sizeThatFits(.unspecified) 1706 subviews[index].place( 1707 at: CGPoint(x: x, y: y), 1708 anchor: .topLeading, 1709 proposal: ProposedViewSize(size) 1710 ) 1711 x += size.width + spacing 1712 } 1713 y += row.height + lineSpacing 1714 } 1715 } 1716 } 1717 1718 private struct WeightedVStack: Layout { 1719 let weights: [CGFloat] 1720 1721 func sizeThatFits( 1722 proposal: ProposedViewSize, 1723 subviews: Subviews, 1724 cache: inout () 1725 ) -> CGSize { 1726 CGSize( 1727 width: proposal.width ?? 0, 1728 height: proposal.height ?? 0 1729 ) 1730 } 1731 1732 func placeSubviews( 1733 in bounds: CGRect, 1734 proposal: ProposedViewSize, 1735 subviews: Subviews, 1736 cache: inout () 1737 ) { 1738 let totalWeight = weights.reduce(0, +) 1739 guard totalWeight > 0 else { return } 1740 1741 var y = bounds.minY 1742 for (index, subview) in subviews.enumerated() { 1743 let weight = index < weights.count ? weights[index] : 0 1744 let height = bounds.height * weight / totalWeight 1745 subview.place( 1746 at: CGPoint(x: bounds.minX, y: y), 1747 anchor: .topLeading, 1748 proposal: ProposedViewSize(width: bounds.width, height: height) 1749 ) 1750 y += height 1751 } 1752 } 1753 }