crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PuzzleView.swift (40997B)


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