PuzzleView.swift (29878B)
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 @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled 54 @State private var isRenaming = false 55 @State private var renameDraft = "" 56 @State private var showErrorsAlert = false 57 @State private var isConfirmingResign = false 58 @State private var isConfirmingDelete = false 59 @State private var isConfirmingLeave = false 60 @State private var revealConfirmation = RevealConfirmation() 61 @State private var isConfirmingClear = false 62 @State private var leaveError: String? 63 @State private var destructiveActionError: String? 64 @State private var isShowingShareSheet = false 65 @State private var hasSolved = false 66 @State private var replay = ReplayControls() 67 @State private var padLayout: PadLayout? 68 /// The most recent size reported while the app was frontmost. Snapshot 69 /// passes the system runs while backgrounded report other orientations; 70 /// those must not disturb the layout, so only active-time sizes are kept 71 /// and re-applied when the scene returns to the foreground. 72 @State private var lastActiveSize: CGSize = .zero 73 /// The shared open "arm" beat: flips a moment after open so the banner and 74 /// the "changed while you were away" borders reveal together. 75 @State private var isArmed = false 76 @State private var announcedIntroGameID: UUID? 77 /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`: 78 /// focusing the rebus field raises the keyboard, and losing focus (e.g. the 79 /// player swipes the keyboard away) commits the buffer. 80 @FocusState private var isRebusFieldFocused: Bool 81 @Environment(\.engagementStatus) private var engagementStatus 82 @Environment(\.scenePhase) private var scenePhase 83 84 private enum PadLayout: Equatable { 85 case landscape 86 case portrait 87 } 88 89 private var effectivePadLayout: PadLayout? { 90 guard UIDevice.current.userInterfaceIdiom == .pad else { return nil } 91 return padLayout ?? .portrait 92 } 93 94 private func swatchImage(for color: PlayerColor) -> Image { 95 let tint = UIColor(color.tint) 96 let base = UIImage(systemName: "circle.fill") ?? UIImage() 97 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 98 } 99 100 private struct TitleParts { 101 let title: String 102 let subtitle: String? 103 } 104 105 private var titleParts: TitleParts { 106 let title = session.puzzle.title 107 let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) 108 let subtitle: String? 109 if let publisher = session.puzzle.publisher, let formattedDate { 110 subtitle = "\(publisher) · \(formattedDate)" 111 } else if let publisher = session.puzzle.publisher { 112 subtitle = publisher 113 } else { 114 subtitle = formattedDate 115 } 116 return TitleParts(title: title, subtitle: subtitle) 117 } 118 119 // Latched completion counts as solved for the read-only presentation 120 // (hides the keyboard, shows the finish panel, disables the controls) even 121 // when the locally merged grid drifted and no longer reads `.solved`. 122 private var isSolved: Bool { hasSolved || session.mutator.isCompleted } 123 124 /// Whether the mutator is read-only (access revoked, unsupported protocol, 125 /// completed) or a sticky, input-blocking announcement is showing. Greys 126 /// out the custom keyboard, makes the hardware-key handler a no-op, and 127 /// disables the toolbar's editing menus. Reads the mutator's own predicate 128 /// so the block holds even if the matching banner was never posted. 129 private var isInputBlocked: Bool { 130 !session.mutator.isEditable 131 || announcements.isInputBlocked(forGame: session.mutator.gameID) 132 } 133 134 private var shouldAutoRevealScoreboard: Bool { 135 onNudge != nil && !isSolved && roster.entries.contains(where: { !$0.isLocal }) 136 } 137 138 var body: some View { 139 Group { 140 switch effectivePadLayout { 141 case .landscape: 142 landscapePadLayout 143 case .portrait: 144 portraitPadLayout 145 case .none: 146 phoneLayout 147 } 148 } 149 .background(Color(.systemBackground)) 150 .background { 151 // Yields first responder during rebus so the focused rebus field 152 // owns input (including hardware keys) and the system keyboard rises. 153 HardwareKeyboardInputView( 154 onPress: handleHardwareKeyboardEvent, 155 isActive: !session.isRebusActive 156 ) 157 .frame(width: 0, height: 0) 158 .allowsHitTesting(false) 159 } 160 .ignoresSafeArea(.keyboard) 161 .onChange(of: session.isRebusActive) { _, active in 162 isRebusFieldFocused = active 163 } 164 .onChange(of: isRebusFieldFocused) { _, focused in 165 // The player dismissed the keyboard (swipe-down / hardware Esc): 166 // treat it as a commit, matching the scrim tap. 167 if !focused, session.isRebusActive { 168 session.commitRebus() 169 } 170 } 171 .modifier(PuzzleToolbarModifier( 172 session: session, 173 roster: roster, 174 shareController: shareController, 175 isSolved: isSolved, 176 isEditingBlocked: isSolved || isInputBlocked, 177 canResign: onResign != nil, 178 canDelete: onDelete != nil, 179 onNudge: onNudge, 180 nudgeReadyAt: nudgeReadyAt, 181 isRenaming: $isRenaming, 182 renameDraft: $renameDraft, 183 isConfirmingResign: $isConfirmingResign, 184 isConfirmingDelete: $isConfirmingDelete, 185 isConfirmingLeave: $isConfirmingLeave, 186 revealConfirmation: revealConfirmation, 187 isConfirmingClear: $isConfirmingClear, 188 isShowingShareSheet: $isShowingShareSheet 189 )) 190 .modifier(PuzzleLifecycleModifier( 191 session: session, 192 roster: roster, 193 hasSolved: $hasSolved, 194 onCompletionEvent: handleCompletionEvent, 195 onSolvedOnAppear: { 196 onComplete?(false) 197 } 198 )) 199 .modifier(PuzzlePresentationModifier( 200 session: session, 201 shareController: shareController, 202 isRenaming: $isRenaming, 203 renameDraft: $renameDraft, 204 showErrorsAlert: $showErrorsAlert, 205 isConfirmingResign: $isConfirmingResign, 206 isConfirmingDelete: $isConfirmingDelete, 207 isConfirmingLeave: $isConfirmingLeave, 208 revealConfirmation: revealConfirmation, 209 isConfirmingClear: $isConfirmingClear, 210 leaveError: $leaveError, 211 destructiveActionError: $destructiveActionError, 212 isShowingShareSheet: $isShowingShareSheet, 213 performResign: performResign, 214 performDelete: performDelete, 215 leaveSharedGame: leaveSharedGame 216 )) 217 // Surfaces the puzzle's actions to the app-level menu so the hold-⌘ 218 // shortcut overlay lists them; reveal routes through the same 219 // confirmation alert the toolbar uses. 220 .focusedSceneValue(\.puzzleActions, PuzzleActionTarget( 221 session: session, 222 revealConfirmation: revealConfirmation, 223 isEnabled: !isSolved && !isInputBlocked 224 )) 225 .frame( 226 width: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.width, 227 height: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.height 228 ) 229 .onGeometryChange(for: CGSize.self) { proxy in 230 proxy.size 231 } action: { newSize in 232 if UIApplication.shared.applicationState == .active { 233 lastActiveSize = newSize 234 } 235 updateLayoutTrait(for: newSize) 236 } 237 .onChange(of: scenePhase) { _, newPhase in 238 // On return to the foreground, re-derive the layout from the last 239 // size seen while active — never from a backgrounded snapshot pass. 240 if newPhase == .active { 241 updateLayoutTrait(for: lastActiveSize, force: true) 242 } 243 } 244 .onAppear { 245 session.onRecentChangesAcknowledged = markPuzzleViewed 246 } 247 .task(id: session.mutator.gameID) { 248 announcePuzzleIntroIfNeeded() 249 // The shared open beat. A short hold lets the puzzle settle and the 250 // on-open sync land; then we arm the banner and capture — once — 251 // which cells a peer changed while we were away, so both reveal 252 // together. Moves that arrive after this are live activity (peer 253 // cursor tints), not part of the away-summary. 254 isArmed = false 255 try? await Task.sleep(for: .milliseconds(750)) 256 isArmed = true 257 if session.mutator.isShared { 258 session.recentChanges = loadRecentChanges() 259 } 260 } 261 } 262 263 private func announcePuzzleIntroIfNeeded() { 264 guard isVoiceOverEnabled, 265 announcedIntroGameID != session.mutator.gameID 266 else { return } 267 announcedIntroGameID = session.mutator.gameID 268 AccessibilityNotification.Announcement(puzzleIntroAnnouncement).post() 269 } 270 271 private var puzzleIntroAnnouncement: String { 272 var parts = [ 273 titleParts.title, 274 "\(session.puzzle.width) by \(session.puzzle.height) puzzle" 275 ] 276 if let shared = sharedPlayersAnnouncement { 277 parts.append(shared) 278 } 279 return parts.joined(separator: ". ") 280 } 281 282 private var sharedPlayersAnnouncement: String? { 283 let names = roster.entries 284 .filter { !$0.isLocal } 285 .map(\.name) 286 .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } 287 guard !names.isEmpty else { return nil } 288 return "Shared with \(Self.formattedList(names))" 289 } 290 291 private static func formattedList(_ items: [String]) -> String { 292 switch items.count { 293 case 0: 294 return "" 295 case 1: 296 return items[0] 297 case 2: 298 return "\(items[0]) and \(items[1])" 299 default: 300 let prefix = items.dropLast().joined(separator: ", ") 301 return "\(prefix), and \(items[items.count - 1])" 302 } 303 } 304 305 private var phoneLayout: some View { 306 VStack(spacing: 0) { 307 puzzleArea() 308 controlsArea(showClueBar: true) 309 } 310 } 311 312 private var landscapePadLayout: some View { 313 VStack(spacing: 0) { 314 HStack(spacing: 0) { 315 VStack(spacing: 0) { 316 if !isSolved { 317 PuzzleScoreboard( 318 session: session, 319 roster: roster, 320 onNudge: onNudge, 321 nudgeReadyAt: nudgeReadyAt 322 ) 323 324 Divider() 325 } 326 327 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 328 } 329 .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) 330 .background(Color(.secondarySystemBackground)) 331 332 Divider() 333 .ignoresSafeArea(edges: .top) 334 335 puzzleArea(bottomInset: 12) 336 .frame(maxWidth: .infinity, maxHeight: .infinity) 337 } 338 .frame(maxWidth: .infinity, maxHeight: .infinity) 339 340 controlsArea(showClueBar: false) 341 } 342 } 343 344 private var portraitPadLayout: some View { 345 VStack(spacing: 0) { 346 WeightedVStack(weights: [3, 1]) { 347 puzzleArea(bottomInset: 12) 348 .frame(maxWidth: .infinity, maxHeight: .infinity) 349 350 VStack(spacing: 0) { 351 Divider() 352 353 HStack(alignment: .top, spacing: 0) { 354 if !isSolved { 355 PuzzleScoreboard( 356 session: session, 357 roster: roster, 358 onNudge: onNudge, 359 nudgeReadyAt: nudgeReadyAt 360 ) 361 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) 362 363 Divider() 364 } 365 366 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 367 .frame(maxWidth: .infinity, maxHeight: .infinity) 368 } 369 .background(Color(.secondarySystemBackground)) 370 } 371 } 372 .frame(maxWidth: .infinity, maxHeight: .infinity) 373 374 controlsArea(showClueBar: false) 375 } 376 } 377 378 private func updateLayoutTrait(for size: CGSize, force: Bool = false) { 379 // Ignore geometry reported while the app is backgrounded: the system 380 // lays the scene out at both orientations to render app-switcher 381 // snapshots, and reacting to those would flip `padLayout` and rebuild 382 // the puzzle subtree — losing the Clue List's scroll position. A forced 383 // re-apply on return to active handles a genuine rotation made while 384 // away. 385 guard force || UIApplication.shared.applicationState == .active else { 386 return 387 } 388 let newLayout: PadLayout? 389 if UIDevice.current.userInterfaceIdiom == .pad { 390 if Self.activeWindowSceneOrientation?.isLandscape == true { 391 newLayout = .landscape 392 } else { 393 newLayout = size.width > size.height ? .landscape : .portrait 394 } 395 } else { 396 newLayout = nil 397 } 398 // Dedupe so re-applying the same value on foreground doesn't needlessly 399 // invalidate the layout. 400 if newLayout != padLayout { 401 padLayout = newLayout 402 } 403 } 404 405 private static var activeWindowSceneOrientation: UIInterfaceOrientation? { 406 UIApplication.shared.connectedScenes 407 .compactMap { $0 as? UIWindowScene } 408 .first { $0.activationState == .foregroundActive }? 409 .interfaceOrientation 410 } 411 412 private func performResign() { 413 do { 414 try onResign?() 415 dismiss() 416 } catch { 417 destructiveActionError = String(describing: error) 418 } 419 } 420 421 private func performDelete() { 422 do { 423 try onDelete?() 424 dismiss() 425 } catch { 426 destructiveActionError = String(describing: error) 427 } 428 } 429 430 private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { 431 switch (event.origin, event.state) { 432 case (_, .incomplete): 433 break 434 case (.observed, .filledWithErrors): 435 // A collaborator's wrong entry must not interrupt the local solver. 436 break 437 case (.local, .filledWithErrors): 438 showErrorsAlert = true 439 case (.local, .solved): 440 guard !hasSolved else { return } 441 hasSolved = true 442 if session.isPencilMode { 443 session.togglePencil() 444 } 445 Task { @MainActor in 446 onComplete?(true) 447 } 448 case (.observed, .solved): 449 guard !hasSolved else { return } 450 hasSolved = true 451 onComplete?(false) 452 } 453 } 454 455 private func puzzleArea(bottomInset: CGFloat = 0) -> some View { 456 ZStack { 457 VStack(spacing: 4) { 458 PuzzleHeader( 459 session: session, 460 roster: roster, 461 title: titleParts.title, 462 subtitle: titleParts.subtitle, 463 showsScoreboard: effectivePadLayout == nil, 464 shouldAutoRevealScoreboard: shouldAutoRevealScoreboard, 465 gameID: session.mutator.gameID, 466 isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, 467 onNudge: onNudge, 468 nudgeReadyAt: nudgeReadyAt, 469 isArmed: isArmed 470 ) 471 GridView( 472 session: session, 473 roster: roster, 474 revealConfirmation: revealConfirmation, 475 showsSharedAnnotations: session.mutator.showsPlayerAttribution, 476 showsPeerCursors: !isSolved, 477 replayFrame: replay.frame 478 ) 479 } 480 .frame(maxWidth: .infinity, maxHeight: .infinity) 481 .padding(.top, -8) 482 // Keep the gap above the clue list inside the ZStack so the rebus 483 // scrim (a sibling below) covers it too, rather than leaving a strip 484 // of background showing between the grid and the clue list. 485 .padding(.bottom, bottomInset) 486 487 if session.isRebusActive { 488 Color.black.opacity(0.35) 489 // Ignore the bottom edge too: with a hardware keyboard there 490 // is no software keyboard covering the bottom safe area, so a 491 // top-only scrim leaves an un-dimmed strip at the screen edge. 492 .ignoresSafeArea(edges: [.top, .bottom]) 493 .contentShape(Rectangle()) 494 .onTapGesture { 495 session.commitRebus() 496 } 497 // No swallow gesture here: the card's opaque background already 498 // blocks taps from reaching the commit scrim beneath, and adding 499 // a tap gesture in front would steal the field's own taps, 500 // limiting the caret to the start/end of the buffer. 501 RebusModal(text: $session.rebusBuffer, isFocused: $isRebusFieldFocused) { 502 session.commitRebus() 503 } 504 .padding(.horizontal) 505 } 506 } 507 } 508 509 private func controlsArea(showClueBar: Bool) -> some View { 510 VStack(spacing: 0) { 511 if showClueBar { 512 ClueBarSlot(session: session, replayFrame: replay.frame) 513 } 514 controlsPanel 515 .frame(height: controlsPanelHeight) 516 } 517 } 518 519 private var controlsPanel: some View { 520 ZStack(alignment: .top) { 521 if isSolved { 522 ControlsView(height: controlsPanelHeight) { 523 SuccessPanel( 524 session: session, 525 roster: roster, 526 replay: replay, 527 loadReplay: loadReplay 528 ) 529 } 530 .transition(.move(edge: .bottom)) 531 } else if showsCustomKeyboard { 532 ControlsView(height: controlsPanelHeight) { 533 KeyboardView(session: session, showsNavigationKeys: effectivePadLayout != nil) 534 .opacity(isInputBlocked ? 0.4 : 1) 535 .allowsHitTesting(!isInputBlocked) 536 .animation(.easeInOut(duration: 0.3), value: isInputBlocked) 537 } 538 .transition(.move(edge: .bottom)) 539 } 540 } 541 .frame(height: controlsPanelHeight, alignment: .top) 542 .background { 543 Color(.systemGroupedBackground) 544 .ignoresSafeArea(edges: .bottom) 545 } 546 .overlay(alignment: .top) { 547 if controlsPanelHeight > 0 { 548 Rectangle() 549 .fill(Color(.opaqueSeparator)) 550 .frame(height: 0.5) 551 } 552 } 553 .animation(.easeOut(duration: 0.25), value: isSolved) 554 .ignoresSafeArea(edges: .bottom) 555 } 556 557 private var controlsPanelHeight: CGFloat { 558 isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 559 } 560 561 private var showsCustomKeyboard: Bool { 562 !inputMonitor.isConnected 563 } 564 565 private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { 566 guard !isSolved, !isInputBlocked else { return false } 567 568 // Undo/redo (⌘Z, ⇧⌘Z) live in the app menu (see PuzzleCommands) so they 569 // appear in the hold-⌘ shortcut overlay. A ⌘-modified Z falls through 570 // the letter case below (which rejects modifiers) and bubbles up to that 571 // menu command. 572 switch event.keyCode { 573 case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, 574 .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, 575 .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, 576 .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, 577 .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, 578 .keyboardZ, 579 .keyboard0, .keyboard1, .keyboard2, .keyboard3, .keyboard4, 580 .keyboard5, .keyboard6, .keyboard7, .keyboard8, .keyboard9: 581 guard !event.modifierFlags.contains(.command), 582 !event.modifierFlags.contains(.control), 583 !event.modifierFlags.contains(.alternate), 584 let character = hardwareKeyboardCharacter(from: event) else { 585 return false 586 } 587 if session.isRebusActive { 588 session.appendRebusLetter(character) 589 } else { 590 session.enter(character) 591 } 592 return true 593 594 case .keyboardDeleteOrBackspace, .keyboardDeleteForward: 595 if session.isRebusActive { 596 session.deleteRebusLetter() 597 } else { 598 session.deleteBackward() 599 } 600 return true 601 602 case .keyboardLeftArrow: 603 guard !session.isRebusActive else { return false } 604 if event.modifierFlags.contains(.command) { 605 session.goToPreviousWord() 606 return true 607 } 608 moveWithHardwareArrow(direction: .across) { 609 session.goToPreviousLetter() 610 } 611 return true 612 613 case .keyboardRightArrow: 614 guard !session.isRebusActive else { return false } 615 if event.modifierFlags.contains(.command) { 616 session.goToNextWord() 617 return true 618 } 619 moveWithHardwareArrow(direction: .across) { 620 session.goToNextLetter() 621 } 622 return true 623 624 case .keyboardUpArrow: 625 guard !session.isRebusActive else { return false } 626 moveWithHardwareArrow(direction: .down) { 627 session.goToPreviousLetter() 628 } 629 return true 630 631 case .keyboardDownArrow: 632 guard !session.isRebusActive else { return false } 633 moveWithHardwareArrow(direction: .down) { 634 session.goToNextLetter() 635 } 636 return true 637 638 case .keyboardTab: 639 guard !session.isRebusActive else { return false } 640 if event.modifierFlags.contains(.shift) { 641 session.goToPreviousClue() 642 } else { 643 session.goToNextClue() 644 } 645 return true 646 647 case .keyboardSpacebar: 648 guard !session.isRebusActive else { return false } 649 session.toggleDirection() 650 return true 651 652 case .keyboardReturnOrEnter: 653 if session.isRebusActive { 654 session.commitRebus() 655 } else { 656 session.toggleDirection() 657 } 658 return true 659 660 case .keyboardEscape: 661 if session.isRebusActive { 662 session.commitRebus() 663 return true 664 } 665 return false 666 667 default: 668 return false 669 } 670 } 671 672 private func hardwareKeyboardCharacter(from event: HardwareKeyboardEvent) -> String? { 673 let scalars = event.charactersIgnoringModifiers.unicodeScalars 674 guard scalars.count == 1, let scalar = scalars.first else { return nil } 675 676 switch scalar.value { 677 case 65...90, 97...122: // A–Z, a–z 678 return String(Character(scalar)).uppercased() 679 case 48...57: // 0–9 680 return String(Character(scalar)) 681 default: 682 return nil 683 } 684 } 685 686 private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { 687 if session.direction != direction { 688 let previousDirection = session.direction 689 session.setDirection(direction) 690 if session.direction != previousDirection { 691 return 692 } 693 } 694 695 move() 696 } 697 698 private func leaveSharedGame() async { 699 guard let shareController else { return } 700 do { 701 try await shareController.leaveShare(gameID: session.mutator.gameID) 702 dismiss() 703 } catch { 704 leaveError = String(describing: error) 705 } 706 } 707 } 708 709 private struct RebusModal: View { 710 @Binding var text: String 711 var isFocused: FocusState<Bool>.Binding 712 let onCommit: () -> Void 713 714 var body: some View { 715 styledField 716 .accessibilityLabel("Rebus entry") 717 .accessibilityValue(accessibilityValue) 718 .accessibilityHint("Enter the full text for this square") 719 .accessibilityAction(.default, onCommit) 720 } 721 722 private var styledField: some View { 723 // An editable field styled to read as the centred display card: the 724 // system keyboard drives entry (so symbols, accents, and emoji are all 725 // reachable) while the look and placement match the prior read-only modal. 726 TextField("", text: $text) 727 .focused(isFocused) 728 .keyboardType(.asciiCapable) 729 .textInputAutocapitalization(.characters) 730 // .textInputAutocapitalization only sets the software keyboard's 731 // shift state; hardware-keyboard input arrives as typed. Uppercase 732 // the buffer directly so rebus fills are capitalised either way, 733 // matching the custom-keyboard path in appendRebusLetter. 734 .onChange(of: text) { _, newValue in 735 let upper = newValue.uppercased() 736 if upper != newValue { text = upper } 737 } 738 .autocorrectionDisabled() 739 .submitLabel(.done) 740 .onSubmit(onCommit) 741 .multilineTextAlignment(.center) 742 .font(.system(size: 32, weight: .semibold, design: .rounded)) 743 .foregroundStyle(.primary) 744 .frame(maxWidth: .infinity, minHeight: 56) 745 .padding(.horizontal, 16) 746 .background(Color(.systemBackground)) 747 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 748 .padding(20) 749 .frame(maxWidth: .infinity) 750 .background(Color(.secondarySystemBackground)) 751 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) 752 } 753 754 private var accessibilityValue: String { 755 text.isEmpty ? "Empty" : text 756 } 757 } 758 759 private struct ControlsView<Content: View>: View { 760 let height: CGFloat 761 @ViewBuilder var content: () -> Content 762 763 var body: some View { 764 VStack(spacing: 0) { 765 content() 766 .frame(height: height) 767 Color(.systemGroupedBackground) 768 } 769 .background(Color(.systemGroupedBackground)) 770 .ignoresSafeArea(edges: .bottom) 771 } 772 }