crossmate

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

PuzzleModifiers.swift (14463B)


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