PuzzleView.swift (25191B)
1 import SwiftUI 2 3 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 /// Sends a broadcast nudge to the other players. `nil` for solo/test 33 /// sessions, which hides the menu button. 34 var onNudge: (() async -> Void)? = nil 35 /// When the next nudge becomes allowed (the send cooldown's end), or `nil` 36 /// if one is allowed right now. A session without nudging wired up hides the 37 /// button regardless (see `onNudge`). 38 var nudgeReadyAt: () -> Date? = { nil } 39 /// Loads the finished game's merged journal for the finish-banner replay 40 /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. 41 var loadReplay: () async -> JournalReplayResult = { .unavailable } 42 /// Cells a peer filled or cleared since this player last viewed the puzzle, 43 /// mapped to the writing author. Read once on the open arm beat. Defaults to 44 /// empty so previews/tests need not wire it. 45 var loadRecentChanges: () -> [GridPosition: String] = { [:] } 46 /// Stamps this game's last-viewed timestamp (device-local). Called when the 47 /// away-change borders are acknowledged. Defaults to a no-op. 48 var markPuzzleViewed: () -> Void = {} 49 @Environment(InputMonitor.self) private var inputMonitor 50 @Environment(PlayerPreferences.self) private var preferences 51 @Environment(AnnouncementCenter.self) private var announcements 52 @Environment(\.dismiss) private var dismiss 53 @State private var isRenaming = false 54 @State private var renameDraft = "" 55 @State private var showErrorsAlert = false 56 @State private var isConfirmingResign = false 57 @State private var isConfirmingDelete = false 58 @State private var isConfirmingLeave = false 59 @State private var isConfirmingReveal = false 60 @State private var pendingRevealScope: RevealScope = .square 61 @State private var leaveError: String? 62 @State private var destructiveActionError: String? 63 @State private var isShowingShareSheet = false 64 @State private var hasSolved = false 65 @State private var replay = ReplayControls() 66 @State private var padLayout: PadLayout? 67 /// The shared open "arm" beat: flips a moment after open so the banner and 68 /// the "changed while you were away" borders reveal together. 69 @State private var isArmed = false 70 /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`: 71 /// focusing the rebus field raises the keyboard, and losing focus (e.g. the 72 /// player swipes the keyboard away) commits the buffer. 73 @FocusState private var isRebusFieldFocused: Bool 74 @Environment(\.engagementStatus) private var engagementStatus 75 76 private enum PadLayout { 77 case landscape 78 case portrait 79 } 80 81 private func swatchImage(for color: PlayerColor) -> Image { 82 let tint = UIColor(color.tint) 83 let base = UIImage(systemName: "circle.fill") ?? UIImage() 84 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 85 } 86 87 private struct TitleParts { 88 let title: String 89 let subtitle: String? 90 } 91 92 private var titleParts: TitleParts { 93 let title = session.puzzle.title 94 let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) 95 let subtitle: String? 96 if let publisher = session.puzzle.publisher, let formattedDate { 97 subtitle = "\(publisher) · \(formattedDate)" 98 } else if let publisher = session.puzzle.publisher { 99 subtitle = publisher 100 } else { 101 subtitle = formattedDate 102 } 103 return TitleParts(title: title, subtitle: subtitle) 104 } 105 106 // Latched completion counts as solved for the read-only presentation 107 // (hides the keyboard, shows the finish panel, disables the controls) even 108 // when the locally merged grid drifted and no longer reads `.solved`. 109 private var isSolved: Bool { hasSolved || session.mutator.isCompleted } 110 111 /// Whether a sticky, input-blocking announcement (currently only 112 /// access revocation) is showing for this game. Greys out the custom 113 /// keyboard and makes the hardware-key handler a no-op. 114 private var isInputBlocked: Bool { 115 announcements.isInputBlocked(forGame: session.mutator.gameID) 116 } 117 118 private var shouldAutoRevealScoreboard: Bool { 119 onNudge != nil && !isSolved && roster.entries.contains(where: { !$0.isLocal }) 120 } 121 122 var body: some View { 123 Group { 124 switch padLayout { 125 case .landscape: 126 landscapePadLayout 127 case .portrait: 128 portraitPadLayout 129 case .none: 130 phoneLayout 131 } 132 } 133 .background(Color(.systemBackground)) 134 .background { 135 // Yields first responder during rebus so the focused rebus field 136 // owns input (including hardware keys) and the system keyboard rises. 137 HardwareKeyboardInputView( 138 onPress: handleHardwareKeyboardEvent, 139 isActive: !session.isRebusActive 140 ) 141 .frame(width: 0, height: 0) 142 .allowsHitTesting(false) 143 } 144 .ignoresSafeArea(.keyboard) 145 .onChange(of: session.isRebusActive) { _, active in 146 isRebusFieldFocused = active 147 } 148 .onChange(of: isRebusFieldFocused) { _, focused in 149 // The player dismissed the keyboard (swipe-down / hardware Esc): 150 // treat it as a commit, matching the scrim tap. 151 if !focused, session.isRebusActive { 152 session.commitRebus() 153 } 154 } 155 .modifier(PuzzleToolbarModifier( 156 session: session, 157 roster: roster, 158 shareController: shareController, 159 isSolved: isSolved, 160 canResign: onResign != nil, 161 canDelete: onDelete != nil, 162 onNudge: onNudge, 163 nudgeReadyAt: nudgeReadyAt, 164 isRenaming: $isRenaming, 165 renameDraft: $renameDraft, 166 isConfirmingResign: $isConfirmingResign, 167 isConfirmingDelete: $isConfirmingDelete, 168 isConfirmingLeave: $isConfirmingLeave, 169 isConfirmingReveal: $isConfirmingReveal, 170 pendingRevealScope: $pendingRevealScope, 171 isShowingShareSheet: $isShowingShareSheet 172 )) 173 .modifier(PuzzleLifecycleModifier( 174 session: session, 175 roster: roster, 176 hasSolved: $hasSolved, 177 onCompletionEvent: handleCompletionEvent, 178 onSolvedOnAppear: { 179 onComplete?(false) 180 } 181 )) 182 .modifier(PuzzlePresentationModifier( 183 session: session, 184 shareController: shareController, 185 isRenaming: $isRenaming, 186 renameDraft: $renameDraft, 187 showErrorsAlert: $showErrorsAlert, 188 isConfirmingResign: $isConfirmingResign, 189 isConfirmingDelete: $isConfirmingDelete, 190 isConfirmingLeave: $isConfirmingLeave, 191 isConfirmingReveal: $isConfirmingReveal, 192 pendingRevealScope: $pendingRevealScope, 193 leaveError: $leaveError, 194 destructiveActionError: $destructiveActionError, 195 isShowingShareSheet: $isShowingShareSheet, 196 performResign: performResign, 197 performDelete: performDelete, 198 leaveSharedGame: leaveSharedGame 199 )) 200 // Surfaces the puzzle's actions to the app-level menu so the hold-⌘ 201 // shortcut overlay lists them; reveal routes through the same 202 // confirmation alert the toolbar uses. 203 .focusedSceneValue(\.puzzleActions, PuzzleActionTarget( 204 session: session, 205 isEnabled: !isSolved && !isInputBlocked, 206 requestReveal: { scope in 207 pendingRevealScope = scope 208 isConfirmingReveal = true 209 } 210 )) 211 .onGeometryChange(for: CGSize.self) { proxy in 212 proxy.size 213 } action: { newSize in 214 updateLayoutTrait(for: newSize) 215 } 216 .onAppear { 217 session.onRecentChangesAcknowledged = markPuzzleViewed 218 } 219 .task(id: session.mutator.gameID) { 220 // The shared open beat. A short hold lets the puzzle settle and the 221 // on-open sync land; then we arm the banner and capture — once — 222 // which cells a peer changed while we were away, so both reveal 223 // together. Moves that arrive after this are live activity (peer 224 // cursor tints), not part of the away-summary. 225 isArmed = false 226 try? await Task.sleep(for: .milliseconds(750)) 227 isArmed = true 228 if session.mutator.isShared { 229 session.recentChanges = loadRecentChanges() 230 } 231 } 232 } 233 234 private var phoneLayout: some View { 235 VStack(spacing: 0) { 236 puzzleArea() 237 controlsArea(showClueBar: true) 238 } 239 } 240 241 private var landscapePadLayout: some View { 242 VStack(spacing: 0) { 243 HStack(spacing: 0) { 244 VStack(spacing: 0) { 245 if !isSolved { 246 PuzzleScoreboard( 247 session: session, 248 roster: roster, 249 onNudge: onNudge, 250 nudgeReadyAt: nudgeReadyAt 251 ) 252 253 Divider() 254 } 255 256 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 257 } 258 .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) 259 .background(Color(.secondarySystemBackground)) 260 261 Divider() 262 .ignoresSafeArea(edges: .top) 263 264 puzzleArea(bottomInset: 12) 265 .frame(maxWidth: .infinity, maxHeight: .infinity) 266 } 267 .frame(maxWidth: .infinity, maxHeight: .infinity) 268 269 controlsArea(showClueBar: false) 270 } 271 } 272 273 private var portraitPadLayout: some View { 274 VStack(spacing: 0) { 275 WeightedVStack(weights: [3, 1]) { 276 puzzleArea(bottomInset: 12) 277 .frame(maxWidth: .infinity, maxHeight: .infinity) 278 279 VStack(spacing: 0) { 280 Divider() 281 282 HStack(alignment: .top, spacing: 0) { 283 if !isSolved { 284 PuzzleScoreboard( 285 session: session, 286 roster: roster, 287 onNudge: onNudge, 288 nudgeReadyAt: nudgeReadyAt 289 ) 290 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) 291 292 Divider() 293 } 294 295 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 296 .frame(maxWidth: .infinity, maxHeight: .infinity) 297 } 298 .background(Color(.secondarySystemBackground)) 299 } 300 } 301 .frame(maxWidth: .infinity, maxHeight: .infinity) 302 303 controlsArea(showClueBar: false) 304 } 305 } 306 307 private func updateLayoutTrait(for size: CGSize) { 308 guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { 309 padLayout = nil 310 return 311 } 312 padLayout = size.width > size.height ? .landscape : .portrait 313 } 314 315 private func performResign() { 316 do { 317 try onResign?() 318 dismiss() 319 } catch { 320 destructiveActionError = String(describing: error) 321 } 322 } 323 324 private func performDelete() { 325 do { 326 try onDelete?() 327 dismiss() 328 } catch { 329 destructiveActionError = String(describing: error) 330 } 331 } 332 333 private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { 334 switch (event.origin, event.state) { 335 case (_, .incomplete): 336 break 337 case (.observed, .filledWithErrors): 338 // A collaborator's wrong entry must not interrupt the local solver. 339 break 340 case (.local, .filledWithErrors): 341 showErrorsAlert = true 342 case (.local, .solved): 343 guard !hasSolved else { return } 344 hasSolved = true 345 if session.isPencilMode { 346 session.togglePencil() 347 } 348 Task { @MainActor in 349 onComplete?(true) 350 } 351 case (.observed, .solved): 352 guard !hasSolved else { return } 353 hasSolved = true 354 onComplete?(false) 355 } 356 } 357 358 private func puzzleArea(bottomInset: CGFloat = 0) -> some View { 359 ZStack { 360 VStack(spacing: 4) { 361 PuzzleHeader( 362 session: session, 363 roster: roster, 364 title: titleParts.title, 365 subtitle: titleParts.subtitle, 366 showsScoreboard: padLayout == nil, 367 shouldAutoRevealScoreboard: shouldAutoRevealScoreboard, 368 gameID: session.mutator.gameID, 369 isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, 370 onNudge: onNudge, 371 nudgeReadyAt: nudgeReadyAt, 372 isArmed: isArmed 373 ) 374 GridView( 375 session: session, 376 roster: roster, 377 showsSharedAnnotations: session.mutator.isShared, 378 showsPeerCursors: !isSolved, 379 replayFrame: replay.frame 380 ) 381 } 382 .frame(maxWidth: .infinity, maxHeight: .infinity) 383 .padding(.top, -8) 384 // Keep the gap above the clue list inside the ZStack so the rebus 385 // scrim (a sibling below) covers it too, rather than leaving a strip 386 // of background showing between the grid and the clue list. 387 .padding(.bottom, bottomInset) 388 389 if session.isRebusActive { 390 Color.black.opacity(0.35) 391 // Ignore the bottom edge too: with a hardware keyboard there 392 // is no software keyboard covering the bottom safe area, so a 393 // top-only scrim leaves an un-dimmed strip at the screen edge. 394 .ignoresSafeArea(edges: [.top, .bottom]) 395 .contentShape(Rectangle()) 396 .onTapGesture { 397 session.commitRebus() 398 } 399 // No swallow gesture here: the card's opaque background already 400 // blocks taps from reaching the commit scrim beneath, and adding 401 // a tap gesture in front would steal the field's own taps, 402 // limiting the caret to the start/end of the buffer. 403 RebusModal(text: $session.rebusBuffer, isFocused: $isRebusFieldFocused) { 404 session.commitRebus() 405 } 406 .padding(.horizontal) 407 } 408 } 409 } 410 411 private func controlsArea(showClueBar: Bool) -> some View { 412 VStack(spacing: 0) { 413 if showClueBar { 414 ClueBarSlot(session: session, replayFrame: replay.frame) 415 } 416 controlsPanel 417 .frame(height: controlsPanelHeight) 418 } 419 } 420 421 private var controlsPanel: some View { 422 ZStack(alignment: .top) { 423 if isSolved { 424 ControlsView(height: controlsPanelHeight) { 425 SuccessPanel( 426 session: session, 427 roster: roster, 428 replay: replay, 429 loadReplay: loadReplay 430 ) 431 } 432 .transition(.move(edge: .bottom)) 433 } else if showsCustomKeyboard { 434 ControlsView(height: controlsPanelHeight) { 435 KeyboardView(session: session, showsNavigationKeys: padLayout != nil) 436 .opacity(isInputBlocked ? 0.4 : 1) 437 .allowsHitTesting(!isInputBlocked) 438 .animation(.easeInOut(duration: 0.3), value: isInputBlocked) 439 } 440 .transition(.move(edge: .bottom)) 441 } 442 } 443 .frame(height: controlsPanelHeight, alignment: .top) 444 .background { 445 Color(.systemGroupedBackground) 446 .ignoresSafeArea(edges: .bottom) 447 } 448 .overlay(alignment: .top) { 449 if controlsPanelHeight > 0 { 450 Rectangle() 451 .fill(Color(.opaqueSeparator)) 452 .frame(height: 0.5) 453 } 454 } 455 .animation(.easeOut(duration: 0.25), value: isSolved) 456 .ignoresSafeArea(edges: .bottom) 457 } 458 459 private var controlsPanelHeight: CGFloat { 460 isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 461 } 462 463 private var showsCustomKeyboard: Bool { 464 !inputMonitor.isConnected 465 } 466 467 private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { 468 guard !isSolved, !isInputBlocked else { return false } 469 470 // Undo/redo (⌘Z, ⇧⌘Z) live in the app menu (see PuzzleCommands) so they 471 // appear in the hold-⌘ shortcut overlay. A ⌘-modified Z falls through 472 // the letter case below (which rejects modifiers) and bubbles up to that 473 // menu command. 474 switch event.keyCode { 475 case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, 476 .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, 477 .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, 478 .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, 479 .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, 480 .keyboardZ, 481 .keyboard0, .keyboard1, .keyboard2, .keyboard3, .keyboard4, 482 .keyboard5, .keyboard6, .keyboard7, .keyboard8, .keyboard9: 483 guard !event.modifierFlags.contains(.command), 484 !event.modifierFlags.contains(.control), 485 !event.modifierFlags.contains(.alternate), 486 let character = hardwareKeyboardCharacter(from: event) else { 487 return false 488 } 489 if session.isRebusActive { 490 session.appendRebusLetter(character) 491 } else { 492 session.enter(character) 493 } 494 return true 495 496 case .keyboardDeleteOrBackspace, .keyboardDeleteForward: 497 if session.isRebusActive { 498 session.deleteRebusLetter() 499 } else { 500 session.deleteBackward() 501 } 502 return true 503 504 case .keyboardLeftArrow: 505 guard !session.isRebusActive else { return false } 506 if event.modifierFlags.contains(.command) { 507 session.goToPreviousWord() 508 return true 509 } 510 moveWithHardwareArrow(direction: .across) { 511 session.goToPreviousLetter() 512 } 513 return true 514 515 case .keyboardRightArrow: 516 guard !session.isRebusActive else { return false } 517 if event.modifierFlags.contains(.command) { 518 session.goToNextWord() 519 return true 520 } 521 moveWithHardwareArrow(direction: .across) { 522 session.goToNextLetter() 523 } 524 return true 525 526 case .keyboardUpArrow: 527 guard !session.isRebusActive else { return false } 528 moveWithHardwareArrow(direction: .down) { 529 session.goToPreviousLetter() 530 } 531 return true 532 533 case .keyboardDownArrow: 534 guard !session.isRebusActive else { return false } 535 moveWithHardwareArrow(direction: .down) { 536 session.goToNextLetter() 537 } 538 return true 539 540 case .keyboardTab: 541 guard !session.isRebusActive else { return false } 542 if event.modifierFlags.contains(.shift) { 543 session.goToPreviousClue() 544 } else { 545 session.goToNextClue() 546 } 547 return true 548 549 case .keyboardSpacebar: 550 guard !session.isRebusActive else { return false } 551 session.toggleDirection() 552 return true 553 554 case .keyboardReturnOrEnter: 555 if session.isRebusActive { 556 session.commitRebus() 557 } else { 558 session.toggleDirection() 559 } 560 return true 561 562 case .keyboardEscape: 563 if session.isRebusActive { 564 session.commitRebus() 565 return true 566 } 567 return false 568 569 default: 570 return false 571 } 572 } 573 574 private func hardwareKeyboardCharacter(from event: HardwareKeyboardEvent) -> String? { 575 let scalars = event.charactersIgnoringModifiers.unicodeScalars 576 guard scalars.count == 1, let scalar = scalars.first else { return nil } 577 578 switch scalar.value { 579 case 65...90, 97...122: // A–Z, a–z 580 return String(Character(scalar)).uppercased() 581 case 48...57: // 0–9 582 return String(Character(scalar)) 583 default: 584 return nil 585 } 586 } 587 588 private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { 589 if session.direction != direction { 590 let previousDirection = session.direction 591 session.setDirection(direction) 592 if session.direction != previousDirection { 593 return 594 } 595 } 596 597 move() 598 } 599 600 private func leaveSharedGame() async { 601 guard let shareController else { return } 602 do { 603 try await shareController.leaveShare(gameID: session.mutator.gameID) 604 dismiss() 605 } catch { 606 leaveError = String(describing: error) 607 } 608 } 609 } 610 611 private struct RebusModal: View { 612 @Binding var text: String 613 var isFocused: FocusState<Bool>.Binding 614 let onCommit: () -> Void 615 616 var body: some View { 617 // An editable field styled to read as the centred display card: the 618 // system keyboard drives entry (so symbols, accents, and emoji are all 619 // reachable) while the look and placement match the prior read-only modal. 620 TextField("", text: $text) 621 .focused(isFocused) 622 .keyboardType(.asciiCapable) 623 .textInputAutocapitalization(.characters) 624 // .textInputAutocapitalization only sets the software keyboard's 625 // shift state; hardware-keyboard input arrives as typed. Uppercase 626 // the buffer directly so rebus fills are capitalised either way, 627 // matching the custom-keyboard path in appendRebusLetter. 628 .onChange(of: text) { _, newValue in 629 let upper = newValue.uppercased() 630 if upper != newValue { text = upper } 631 } 632 .autocorrectionDisabled() 633 .submitLabel(.done) 634 .onSubmit(onCommit) 635 .multilineTextAlignment(.center) 636 .font(.system(size: 32, weight: .semibold, design: .rounded)) 637 .foregroundStyle(.primary) 638 .frame(maxWidth: .infinity, minHeight: 56) 639 .padding(.horizontal, 16) 640 .background(Color(.systemBackground)) 641 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 642 .padding(20) 643 .frame(maxWidth: .infinity) 644 .background(Color(.secondarySystemBackground)) 645 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) 646 } 647 } 648 649 private struct ControlsView<Content: View>: View { 650 let height: CGFloat 651 @ViewBuilder var content: () -> Content 652 653 var body: some View { 654 VStack(spacing: 0) { 655 content() 656 .frame(height: height) 657 Color(.systemGroupedBackground) 658 } 659 .background(Color(.systemGroupedBackground)) 660 .ignoresSafeArea(edges: .bottom) 661 } 662 }