crossmate

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

PuzzleModifiers.swift (15530B)


      1 import SwiftUI
      2 
      3 struct PuzzleToolbarModifier: ViewModifier {
      4     let session: PlayerSession
      5     let roster: PlayerRoster
      6     let shareController: ShareController?
      7     let isSolved: Bool
      8     /// The single read-only gate for the editing controls: solved, access
      9     /// revoked, unsupported protocol, or an input-blocking banner. The Players
     10     /// menu stays on `isSolved` alone so a revoked participant can still see
     11     /// the roster and leave the puzzle.
     12     let isEditingBlocked: Bool
     13     let canResign: Bool
     14     let canDelete: Bool
     15     /// Sends a broadcast nudge to the other players; `nil` hides the button
     16     /// (solo/test sessions).
     17     var onNudge: (() async -> Void)? = nil
     18     /// Whether a nudge is allowed right now (cooldown elapsed). Read when the
     19     /// menu is built.
     20     var nudgeReadyAt: () -> Date? = { nil }
     21     @Binding var isRenaming: Bool
     22     @Binding var renameDraft: String
     23     @Binding var isConfirmingResign: Bool
     24     @Binding var isConfirmingDelete: Bool
     25     @Binding var isConfirmingLeave: Bool
     26     let revealConfirmation: RevealConfirmation
     27     @Binding var isConfirmingClear: Bool
     28     @Binding var isShowingShareSheet: Bool
     29     @Environment(PlayerPreferences.self) private var preferences
     30     @AppStorage("debugMode") private var debugMode = false
     31     @State private var isShowingDiagnostics = false
     32 
     33     func body(content: Content) -> some View {
     34         content
     35             .toolbar {
     36                 ToolbarItemGroup(placement: .topBarTrailing) {
     37                     pencilButton
     38                     entryMenu
     39                     hintsMenu
     40                     playersMenu
     41                 }
     42             }
     43             .sheet(isPresented: $isShowingDiagnostics) {
     44                 NavigationStack {
     45                     DiagnosticsView()
     46                         .toolbar {
     47                             ToolbarItem(placement: .cancellationAction) {
     48                                 Button {
     49                                     isShowingDiagnostics = false
     50                                 } label: {
     51                                     Image(systemName: "xmark")
     52                                 }
     53                                 .accessibilityLabel("Close")
     54                             }
     55                         }
     56                 }
     57             }
     58     }
     59 
     60     private func swatchImage(for color: PlayerColor) -> Image {
     61         let tint = UIColor(color.tint)
     62         let base = UIImage(systemName: "circle.fill") ?? UIImage()
     63         return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal))
     64     }
     65 
     66     private var pencilButton: some View {
     67         Button {
     68             session.togglePencil()
     69         } label: {
     70             Image(systemName: "pencil")
     71                 .foregroundStyle(pencilButtonForeground)
     72                 .padding(6)
     73                 .pencilGlass(
     74                     isActive: !isEditingBlocked && session.isPencilMode,
     75                     tint: preferences.color.tint
     76                 )
     77         }
     78         .accessibilityLabel(session.isPencilMode ? "Turn Off Draft" : "Turn On Draft")
     79         .disabled(isEditingBlocked)
     80     }
     81 
     82     private var pencilButtonForeground: Color {
     83         if isEditingBlocked {
     84             return .secondary
     85         }
     86         if session.isPencilMode {
     87             return .white
     88         }
     89         if #available(iOS 26.0, *) {
     90             return .primary
     91         }
     92         return .accentColor
     93     }
     94 
     95     private var entryMenu: some View {
     96         Menu {
     97             Section {
     98                 Button("Undo Move") { session.undo() }
     99                     .disabled(!session.canUndo)
    100                 Button("Redo Move") { session.redo() }
    101                     .disabled(!session.canRedo)
    102             }
    103 
    104             Section {
    105                 Button("Enter Rebus") { session.startRebus() }
    106                 Button("Toggle Direction") { session.toggleDirection() }
    107             }
    108 
    109             if debugMode {
    110                 Section {
    111                     Button {
    112                         isShowingDiagnostics = true
    113                     } label: {
    114                         Text("Diagnostics Log")
    115                     }
    116                 }
    117             }
    118 
    119             Section {
    120                 Button("Clear Word") { session.clearCurrentWord() }
    121                 Button("Clear Puzzle", role: .destructive) { isConfirmingClear = true }
    122             }
    123         } label: {
    124             Label("Entry", systemImage: "squareshape.split.2x2")
    125         }
    126         .disabled(isEditingBlocked)
    127     }
    128 
    129     private var hintsMenu: some View {
    130         Menu {
    131             Section {
    132                 Button("Check Square") { session.checkSquare() }
    133                 Button("Check Word") { session.checkCurrentWord() }
    134                 Button("Check Puzzle") { session.checkPuzzle() }
    135             }
    136             Section {
    137                 Button("Fill Quarter") { session.fillQuarter() }
    138                 Button("Fill Half") { session.fillHalf() }
    139             }
    140             Section {
    141                 Button("Reveal Square") { revealConfirmation.request(.square) }
    142                 Button("Reveal Word") { revealConfirmation.request(.word) }
    143                 Button("Reveal Puzzle", role: .destructive) { revealConfirmation.request(.puzzle) }
    144             }
    145         } label: {
    146             Label("Hints", systemImage: "lightbulb")
    147         }
    148         .disabled(isEditingBlocked)
    149     }
    150 
    151     private var playersMenu: some View {
    152         Menu {
    153             playerRosterSection
    154             playerPreferencesSection
    155             shareSection
    156             puzzleDestructiveSection
    157         } label: {
    158             Label("Players", systemImage: "person.2")
    159         }
    160         .disabled(isSolved)
    161     }
    162 
    163     @ViewBuilder
    164     private var playerRosterSection: some View {
    165         Section {
    166             if !roster.entries.isEmpty {
    167                 ForEach(roster.entries) { entry in
    168                     Button {} label: {
    169                         Label {
    170                             Text(entry.isLocal ? "\(entry.name) (you)" : entry.name)
    171                         } icon: {
    172                             swatchImage(for: entry.color)
    173                         }
    174                     }
    175                     .disabled(true)
    176                 }
    177             } else {
    178                 Button {} label: {
    179                     Label {
    180                         Text(preferences.name)
    181                     } icon: {
    182                         swatchImage(for: preferences.color)
    183                     }
    184                 }
    185                 .disabled(true)
    186             }
    187         }
    188         nudgeSection
    189     }
    190 
    191     /// Broadcast "Nudge Players" action. Shown only when nudging is wired up
    192     /// (a shared session) and there is at least one other player to rouse;
    193     /// disabled while the puzzle is solved or the per-game cooldown is still
    194     /// running. The Menu rebuilds each time it opens, so `nudgeReadyAt()` is read
    195     /// fresh and the disabled state tracks the cooldown without observation.
    196     @ViewBuilder
    197     private var nudgeSection: some View {
    198         if let onNudge, roster.entries.contains(where: { !$0.isLocal }) {
    199             Section {
    200                 Button("Nudge Players") {
    201                     Task { await onNudge() }
    202                 }
    203                 .disabled(isEditingBlocked || nudgeReadyAt() != nil)
    204             }
    205         }
    206     }
    207 
    208     private var playerPreferencesSection: some View {
    209         Section {
    210             Menu("Change Colour") {
    211                 ForEach(PlayerColor.palette) { color in
    212                     Button {
    213                         preferences.color = color
    214                         // Friend colours are derived with the local user's
    215                         // colour reserved, so refreshing re-derives and bumps
    216                         // any friend that now collides with the new choice.
    217                         Task { await roster.refresh() }
    218                     } label: {
    219                         Label {
    220                             Text(color.id == preferences.colorID ? "\(color.name)  ✓" : color.name)
    221                         } icon: {
    222                             swatchImage(for: color)
    223                         }
    224                     }
    225                 }
    226             }
    227 
    228             Button("Change Name") {
    229                 renameDraft = preferences.name
    230                 isRenaming = true
    231             }
    232         }
    233     }
    234 
    235     @ViewBuilder
    236     private var shareSection: some View {
    237         if shareController != nil {
    238             Section {
    239                 Button {
    240                     isShowingShareSheet = true
    241                 } label: {
    242                     Text("Invite Players")
    243                 }
    244                 .disabled(!session.mutator.isOwned)
    245             }
    246         }
    247     }
    248 
    249     private var puzzleDestructiveSection: some View {
    250         Section {
    251             Button("Resign Puzzle", role: .destructive) {
    252                 isConfirmingResign = true
    253             }
    254             .disabled(isEditingBlocked || !canResign)
    255 
    256             if session.mutator.isShared && !session.mutator.isOwned {
    257                 Button("Leave Puzzle", role: .destructive) {
    258                     isConfirmingLeave = true
    259                 }
    260                 .disabled(shareController == nil)
    261             } else {
    262                 Button("Delete Puzzle", role: .destructive) {
    263                     isConfirmingDelete = true
    264                 }
    265                 .disabled(!canDelete)
    266             }
    267         }
    268     }
    269 }
    270 
    271 struct PuzzleLifecycleModifier: ViewModifier {
    272     let session: PlayerSession
    273     let roster: PlayerRoster
    274     @Binding var hasSolved: Bool
    275     let onCompletionEvent: (PlayerSession.CompletionEvent) -> Void
    276     let onSolvedOnAppear: () -> Void
    277 
    278     func body(content: Content) -> some View {
    279         content
    280             .onAppear {
    281                 if session.game.completionState == .solved {
    282                     hasSolved = true
    283                     onSolvedOnAppear()
    284                 }
    285             }
    286             .onChange(of: session.completionEvent) { _, newValue in
    287                 guard let newValue else { return }
    288                 onCompletionEvent(newValue)
    289             }
    290     }
    291 }
    292 
    293 struct PuzzlePresentationModifier: ViewModifier {
    294     let session: PlayerSession
    295     let shareController: ShareController?
    296     @Binding var isRenaming: Bool
    297     @Binding var renameDraft: String
    298     @Binding var showErrorsAlert: Bool
    299     @Binding var isConfirmingResign: Bool
    300     @Binding var isConfirmingDelete: Bool
    301     @Binding var isConfirmingLeave: Bool
    302     let revealConfirmation: RevealConfirmation
    303     @Binding var isConfirmingClear: Bool
    304     @Binding var leaveError: String?
    305     @Binding var destructiveActionError: String?
    306     @Binding var isShowingShareSheet: Bool
    307     let performResign: () -> Void
    308     let performDelete: () -> Void
    309     let leaveSharedGame: () async -> Void
    310     @Environment(PlayerPreferences.self) private var preferences
    311 
    312     func body(content: Content) -> some View {
    313         @Bindable var revealConfirmation = revealConfirmation
    314         content
    315             .alert("Not Quite Right", isPresented: $showErrorsAlert) {
    316                 Button("OK", role: .cancel) {}
    317             } message: {
    318                 Text("One or more squares are incorrect.")
    319             }
    320             .alert("Resign Puzzle?", isPresented: $isConfirmingResign) {
    321                 Button("Resign", role: .destructive) {
    322                     performResign()
    323                 }
    324                 Button("Cancel", role: .cancel) {}
    325             } message: {
    326                 Text("This will reveal the puzzle and mark it complete.")
    327             }
    328             .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) {
    329                 Button("Delete", role: .destructive) {
    330                     performDelete()
    331                 }
    332                 Button("Cancel", role: .cancel) {}
    333             } message: {
    334                 deleteConfirmationMessage
    335             }
    336             .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) {
    337                 Button("Leave", role: .destructive) {
    338                     Task { await leaveSharedGame() }
    339                 }
    340                 Button("Cancel", role: .cancel) {}
    341             } message: {
    342                 Text("You will lose access to \"\(session.puzzle.title)\".")
    343             }
    344             .alert(
    345                 revealConfirmation.pendingScope.title,
    346                 isPresented: $revealConfirmation.isConfirming
    347             ) {
    348                 Button("Reveal", role: .destructive) {
    349                     // The announcement replaces the direct spoken feedback the
    350                     // VoiceOver cell action gave before it routed through this
    351                     // alert; it is a no-op without assistive tech running.
    352                     if let announcement = revealConfirmation.performPending(on: session) {
    353                         AccessibilityNotification.Announcement(announcement).post()
    354                     }
    355                 }
    356                 Button("Cancel", role: .cancel) {}
    357             } message: {
    358                 Text(revealConfirmation.pendingScope.message)
    359             }
    360             .alert("Clear Puzzle?", isPresented: $isConfirmingClear) {
    361                 Button("Clear", role: .destructive) {
    362                     session.clearPuzzle()
    363                 }
    364                 Button("Cancel", role: .cancel) {}
    365             } message: {
    366                 Text("This will clear all entered squares in the puzzle.")
    367             }
    368             .alert(
    369                 "Couldn't Leave",
    370                 isPresented: .init(
    371                     get: { leaveError != nil },
    372                     set: { if !$0 { leaveError = nil } }
    373                 ),
    374                 presenting: leaveError
    375             ) { _ in
    376                 Button("OK", role: .cancel) {}
    377             } message: { message in
    378                 Text(message)
    379             }
    380             .alert(
    381                 "Couldn't Update Puzzle",
    382                 isPresented: .init(
    383                     get: { destructiveActionError != nil },
    384                     set: { if !$0 { destructiveActionError = nil } }
    385                 ),
    386                 presenting: destructiveActionError
    387             ) { _ in
    388                 Button("OK", role: .cancel) {}
    389             } message: { message in
    390                 Text(message)
    391             }
    392             .alert("Change Name", isPresented: $isRenaming) {
    393                 TextField("Name", text: $renameDraft)
    394                     .textInputAutocapitalization(.never)
    395                     .autocorrectionDisabled()
    396                 Button("Cancel", role: .cancel) {}
    397                 Button("Save") {
    398                     let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
    399                     if !trimmed.isEmpty {
    400                         preferences.name = trimmed
    401                     }
    402                 }
    403                 .keyboardShortcut(.defaultAction)
    404             } message: {
    405                 Text("Enter the name other players will see.")
    406             }
    407             .sheet(isPresented: $isShowingShareSheet) {
    408                 if let shareController {
    409                     GameShareSheet(
    410                         gameID: session.mutator.gameID,
    411                         title: session.puzzle.title,
    412                         shareController: shareController
    413                     )
    414                 }
    415             }
    416     }
    417 
    418     private var deleteConfirmationMessage: Text {
    419         if session.mutator.isOwned && session.mutator.isShared {
    420             Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.")
    421         } else {
    422             Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.")
    423         }
    424     }
    425 }