crossmate

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

commit df107211e64fbd73de2375f783c34e85aaaf35cc
parent 43ad4137f505113dac43732872cca3ec2c100de4
Author: Michael Camilleri <[email protected]>
Date:   Sun,  3 May 2026 00:28:14 +0900

Improve consistency between game list menu and puzzle menu

This commit attempts to provide consistent actions for puzzles, whether
that be for puzzles viewed in the game list or puzzles viewed in the
puzzle viewer.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 4+++-
MCrossmate/Views/GameListView.swift | 13+++++++++----
MCrossmate/Views/PuzzleView.swift | 549++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
3 files changed, 387 insertions(+), 179 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -292,7 +292,9 @@ private struct PuzzleDisplayView: View { session: session, shareController: shareController, roster: roster, - onComplete: { store.markCompleted(id: gameID) } + onComplete: { store.markCompleted(id: gameID) }, + onResign: { try store.resignGame(id: gameID) }, + onDelete: { try store.deleteGame(id: gameID) } ) } else if let loadError { ContentUnavailableView( diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -251,13 +251,18 @@ private struct GameRowView: View { Label("Share", systemImage: "square.and.arrow.up") } .disabled(!game.isOwned) - Button { onLeave() } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") } - .disabled(!(!game.isOwned && game.isShared)) Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") } Section { Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") } - Button(role: .destructive) { onDelete() } label: { Label("Delete", systemImage: "trash") } - .disabled(!game.isOwned && game.isShared) + if !game.isOwned && game.isShared { + Button(role: .destructive) { onLeave() } label: { + Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") + } + } else { + Button(role: .destructive) { onDelete() } label: { + Label("Delete", systemImage: "trash") + } + } } } label: { Image(systemName: "ellipsis") diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -5,13 +5,18 @@ struct PuzzleView: View { var shareController: ShareController? = nil var roster: PlayerRoster? = nil var onComplete: (() -> Void)? = nil + var onResign: (() throws -> Void)? = nil + var onDelete: (() throws -> Void)? = nil @Environment(PlayerPreferences.self) private var preferences @Environment(\.dismiss) private var dismiss @State private var isRenaming = false @State private var renameDraft = "" @State private var showErrorsAlert = false + @State private var isConfirmingResign = false + @State private var isConfirmingDelete = false @State private var isConfirmingLeave = false @State private var leaveError: String? + @State private var destructiveActionError: String? @State private var isRevokedBannerDismissed = false @State private var isShowingShareSheet = false @State private var hasSolved = false @@ -54,182 +59,59 @@ struct PuzzleView: View { } .background(Color(.systemBackground)) .ignoresSafeArea(.keyboard) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - session.togglePencil() - } label: { - Image(systemName: "pencil") - .foregroundStyle(session.isPencilMode ? Color.white : Color.primary) - .padding(6) - .glassEffect( - session.isPencilMode - ? .regular.tint(preferences.color.tint) - : .identity, - in: Circle() - ) - } - .accessibilityLabel("Pencil") - .disabled(isSolved) - - Menu { - Section { - Button("Check Square") { session.checkSquare() } - Button("Check Word") { session.checkCurrentWord() } - Button("Check Puzzle") { session.checkPuzzle() } - } - Section { - Button("Reveal Square") { session.revealSquare() } - Button("Reveal Word") { session.revealCurrentWord() } - Button("Reveal Puzzle") { session.revealPuzzle() } - } - } label: { - Label("Hints", systemImage: "lightbulb") - } - .disabled(isSolved) - - Menu { - Button("Clear Word") { session.clearCurrentWord() } - Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } - } label: { - Label("Clear", systemImage: "eraser") - } - .disabled(isSolved) - - Menu { - Section { - if let roster, !roster.entries.isEmpty { - ForEach(roster.entries) { entry in - Button {} label: { - Label { - Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) - } icon: { - swatchImage(for: entry.color) - } - } - .disabled(true) - } - } else { - Button {} label: { - Label { - Text(preferences.name) - } icon: { - swatchImage(for: preferences.color) - } - } - .disabled(true) - } - } - - Section { - Menu("Change Colour") { - ForEach(PlayerColor.palette) { color in - Button { - preferences.color = color - if let roster { - Task { await roster.reassignOnLocalColorChange(newColor: color) } - } - } label: { - Label { - Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) - } icon: { - swatchImage(for: color) - } - } - } - } - - Button("Change Name") { - renameDraft = preferences.name - isRenaming = true - } - } - - Section { - if shareController != nil { - Button { - isShowingShareSheet = true - } label: { - Text("Share Game") - } - .disabled(!session.mutator.isOwned) - } + .modifier(PuzzleToolbarModifier( + session: session, + roster: roster, + shareController: shareController, + isSolved: isSolved, + canResign: onResign != nil, + canDelete: onDelete != nil, + isRenaming: $isRenaming, + renameDraft: $renameDraft, + isConfirmingResign: $isConfirmingResign, + isConfirmingDelete: $isConfirmingDelete, + isConfirmingLeave: $isConfirmingLeave, + isShowingShareSheet: $isShowingShareSheet + )) + .modifier(PuzzleLifecycleModifier( + session: session, + roster: roster, + hasSolved: $hasSolved, + onCompletionStateChanged: handleCompletionState + )) + .modifier(PuzzlePresentationModifier( + session: session, + shareController: shareController, + isRenaming: $isRenaming, + renameDraft: $renameDraft, + showErrorsAlert: $showErrorsAlert, + isConfirmingResign: $isConfirmingResign, + isConfirmingDelete: $isConfirmingDelete, + isConfirmingLeave: $isConfirmingLeave, + leaveError: $leaveError, + destructiveActionError: $destructiveActionError, + isShowingShareSheet: $isShowingShareSheet, + performResign: performResign, + performDelete: performDelete, + leaveSharedGame: leaveSharedGame + )) + } - Button("Leave Game", role: .destructive) { - isConfirmingLeave = true - } - .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil) - } - } label: { - Label("Players", systemImage: "person.2") - } - } - } - .task { - guard let roster else { return } - await roster.refresh() - } - .onAppear { - if session.game.completionState == .solved { - hasSolved = true - } - } - .onAppear { - session.onCompletionStateChanged = { newValue in - handleCompletionState(newValue) - } - } - .onDisappear { - session.onCompletionStateChanged = nil - } - .alert("Not Quite Right", isPresented: $showErrorsAlert) { - Button("OK", role: .cancel) {} - } message: { - Text("One or more squares are incorrect.") - } - .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { - Button("Leave", role: .destructive) { - Task { await leaveSharedGame() } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("You will lose access to \"\(session.puzzle.title)\".") - } - .alert( - "Couldn't Leave", - isPresented: .init( - get: { leaveError != nil }, - set: { if !$0 { leaveError = nil } } - ), - presenting: leaveError - ) { _ in - Button("OK", role: .cancel) {} - } message: { message in - Text(message) - } - .alert("Change Name", isPresented: $isRenaming) { - TextField("Name", text: $renameDraft) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button("Cancel", role: .cancel) {} - Button("Save") { - let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - preferences.name = trimmed - } - } - .keyboardShortcut(.defaultAction) - } message: { - Text("Enter the name other players will see.") + private func performResign() { + do { + try onResign?() + dismiss() + } catch { + destructiveActionError = String(describing: error) } - .sheet(isPresented: $isShowingShareSheet) { - if let shareController { - GameShareSheet( - gameID: session.mutator.gameID, - title: session.puzzle.title, - shareController: shareController - ) - } + } + + private func performDelete() { + do { + try onDelete?() + dismiss() + } catch { + destructiveActionError = String(describing: error) } } @@ -308,6 +190,325 @@ struct PuzzleView: View { } } +private struct PuzzleToolbarModifier: ViewModifier { + let session: PlayerSession + let roster: PlayerRoster? + let shareController: ShareController? + let isSolved: Bool + let canResign: Bool + let canDelete: Bool + @Binding var isRenaming: Bool + @Binding var renameDraft: String + @Binding var isConfirmingResign: Bool + @Binding var isConfirmingDelete: Bool + @Binding var isConfirmingLeave: Bool + @Binding var isShowingShareSheet: Bool + @Environment(PlayerPreferences.self) private var preferences + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + pencilButton + hintsMenu + clearMenu + playersMenu + } + } + } + + private func swatchImage(for color: PlayerColor) -> Image { + let tint = UIColor(color.tint) + let base = UIImage(systemName: "circle.fill") ?? UIImage() + return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) + } + + private var pencilButton: some View { + Button { + session.togglePencil() + } label: { + Image(systemName: "pencil") + .foregroundStyle(session.isPencilMode ? Color.white : Color.primary) + .padding(6) + .glassEffect( + session.isPencilMode + ? .regular.tint(preferences.color.tint) + : .identity, + in: Circle() + ) + } + .accessibilityLabel("Pencil") + .disabled(isSolved) + } + + private var hintsMenu: some View { + Menu { + Section { + Button("Check Square") { session.checkSquare() } + Button("Check Word") { session.checkCurrentWord() } + Button("Check Puzzle") { session.checkPuzzle() } + } + Section { + Button("Reveal Square") { session.revealSquare() } + Button("Reveal Word") { session.revealCurrentWord() } + Button("Reveal Puzzle") { session.revealPuzzle() } + } + } label: { + Label("Hints", systemImage: "lightbulb") + } + .disabled(isSolved) + } + + private var clearMenu: some View { + Menu { + Button("Clear Word") { session.clearCurrentWord() } + Button("Clear Puzzle", role: .destructive) { session.clearPuzzle() } + } label: { + Label("Clear", systemImage: "eraser") + } + .disabled(isSolved) + } + + private var playersMenu: some View { + Menu { + playerRosterSection + playerPreferencesSection + shareSection + puzzleDestructiveSection + } label: { + Label("Players", systemImage: "person.2") + } + } + + @ViewBuilder + private var playerRosterSection: some View { + Section { + if let roster, !roster.entries.isEmpty { + ForEach(roster.entries) { entry in + Button {} label: { + Label { + Text(entry.isLocal ? "\(entry.name) (you)" : entry.name) + } icon: { + swatchImage(for: entry.color) + } + } + .disabled(true) + } + } else { + Button {} label: { + Label { + Text(preferences.name) + } icon: { + swatchImage(for: preferences.color) + } + } + .disabled(true) + } + } + } + + private var playerPreferencesSection: some View { + Section { + Menu("Change Colour") { + ForEach(PlayerColor.palette) { color in + Button { + preferences.color = color + if let roster { + Task { await roster.reassignOnLocalColorChange(newColor: color) } + } + } label: { + Label { + Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) + } icon: { + swatchImage(for: color) + } + } + } + } + + Button("Change Name") { + renameDraft = preferences.name + isRenaming = true + } + } + } + + @ViewBuilder + private var shareSection: some View { + if shareController != nil { + Section { + Button { + isShowingShareSheet = true + } label: { + Text("Share Game") + } + .disabled(!session.mutator.isOwned) + } + } + } + + private var puzzleDestructiveSection: some View { + Section { + Button(role: .destructive) { + isConfirmingResign = true + } label: { + Label("Resign", systemImage: "flag") + } + .disabled(isSolved || !canResign) + + if session.mutator.isShared && !session.mutator.isOwned { + Button(role: .destructive) { + isConfirmingLeave = true + } label: { + Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") + } + .disabled(shareController == nil) + } else { + Button(role: .destructive) { + isConfirmingDelete = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(!canDelete) + } + } + } +} + +private struct PuzzleLifecycleModifier: ViewModifier { + let session: PlayerSession + let roster: PlayerRoster? + @Binding var hasSolved: Bool + let onCompletionStateChanged: (Game.CompletionState) -> Void + + func body(content: Content) -> some View { + content + .task { + guard let roster else { return } + await roster.refresh() + } + .onAppear { + if session.game.completionState == .solved { + hasSolved = true + } + } + .onAppear { + session.onCompletionStateChanged = { newValue in + onCompletionStateChanged(newValue) + } + } + .onDisappear { + session.onCompletionStateChanged = nil + } + } +} + +private struct PuzzlePresentationModifier: ViewModifier { + let session: PlayerSession + let shareController: ShareController? + @Binding var isRenaming: Bool + @Binding var renameDraft: String + @Binding var showErrorsAlert: Bool + @Binding var isConfirmingResign: Bool + @Binding var isConfirmingDelete: Bool + @Binding var isConfirmingLeave: Bool + @Binding var leaveError: String? + @Binding var destructiveActionError: String? + @Binding var isShowingShareSheet: Bool + let performResign: () -> Void + let performDelete: () -> Void + let leaveSharedGame: () async -> Void + @Environment(PlayerPreferences.self) private var preferences + + func body(content: Content) -> some View { + content + .alert("Not Quite Right", isPresented: $showErrorsAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("One or more squares are incorrect.") + } + .alert("Resign Puzzle?", isPresented: $isConfirmingResign) { + Button("Resign", role: .destructive) { + performResign() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will reveal the puzzle and mark it complete.") + } + .alert("Delete Puzzle?", isPresented: $isConfirmingDelete) { + Button("Delete", role: .destructive) { + performDelete() + } + Button("Cancel", role: .cancel) {} + } message: { + deleteConfirmationMessage + } + .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { + Button("Leave", role: .destructive) { + Task { await leaveSharedGame() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You will lose access to \"\(session.puzzle.title)\".") + } + .alert( + "Couldn't Leave", + isPresented: .init( + get: { leaveError != nil }, + set: { if !$0 { leaveError = nil } } + ), + presenting: leaveError + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + .alert( + "Couldn't Update Puzzle", + isPresented: .init( + get: { destructiveActionError != nil }, + set: { if !$0 { destructiveActionError = nil } } + ), + presenting: destructiveActionError + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } + .alert("Change Name", isPresented: $isRenaming) { + TextField("Name", text: $renameDraft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Cancel", role: .cancel) {} + Button("Save") { + let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + preferences.name = trimmed + } + } + .keyboardShortcut(.defaultAction) + } message: { + Text("Enter the name other players will see.") + } + .sheet(isPresented: $isShowingShareSheet) { + if let shareController { + GameShareSheet( + gameID: session.mutator.gameID, + title: session.puzzle.title, + shareController: shareController + ) + } + } + } + + private var deleteConfirmationMessage: Text { + if session.mutator.isOwned && session.mutator.isShared { + Text("This will permanently delete \"\(session.puzzle.title)\" from iCloud for everyone.") + } else { + Text("This will permanently delete \"\(session.puzzle.title)\" and all progress.") + } + } +} + private struct PuzzleTitle: View { let title: String let subtitle: String?