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 }