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 }