crossmate

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

commit 4ca111dd76b777ecb578085c9766f1d7affa6809
parent 34685d68a58e23489cf2c229a6f8378897e56a1c
Author: Michael Camilleri <[email protected]>
Date:   Fri,  5 Jun 2026 08:20:01 +0900

Show wrong-fill alert only for local attempts

Prior to this commit, a fully-filled wrong puzzle stayed in
.filledWithErrors when the user overwrote one wrong letter with another,
so PuzzleView's completion-state observer had no transition to react to.
The 'Not Quite Right' alert only appeared when the final empty square
was filled.

PlayerSession now publishes a local completion event with a fresh
sequence number whenever local input reaches a terminal completion
state. PuzzleView uses that event for the wrong-fill alert, while
observed .filledWithErrors transitions are ignored so a collaborator's
wrong entry does not interrupt the local user. Remote .solved still
flows through the observed completion-state path.

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

Diffstat:
MCrossmate/Models/PlayerSession.swift | 21++++++++++++++++-----
MCrossmate/Views/PuzzleView.swift | 14++++----------
MTests/Unit/PlayerSessionNavigationTests.swift | 25+++++++++++++++++++++++++
3 files changed, 45 insertions(+), 15 deletions(-)

diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -45,11 +45,18 @@ final class PlayerSession { @ObservationIgnored private var lastPublishedCursorTrack: PlayerSelection? - /// Optional sink fired after local mutations that can fill or solve the - /// puzzle. This keeps completion UI out of SwiftUI's render-time body - /// evaluation path. + /// Published after local mutations that can fill or solve the puzzle. + /// Unlike `Game.completionState`, this is an event: repeated failed + /// attempts on an already-full wrong grid still get a fresh sequence. + struct CompletionEvent: Equatable { + let sequence: Int + let state: Game.CompletionState + } + + var localCompletionEvent: CompletionEvent? + @ObservationIgnored - var onCompletionStateChanged: ((Game.CompletionState) -> Void)? + private var localCompletionEventSequence = 0 /// Rebus mode lets the player type a multi-character value into a single /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in @@ -472,7 +479,11 @@ final class PlayerSession { private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) { let state = state ?? game.completionState guard state != .incomplete else { return } - onCompletionStateChanged?(state) + localCompletionEventSequence += 1 + localCompletionEvent = CompletionEvent( + sequence: localCompletionEventSequence, + state: state + ) } private func advance() { diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -272,10 +272,8 @@ struct PuzzleView: View { private func handleObservedCompletionState(_ newValue: Game.CompletionState) { switch newValue { - case .incomplete: + case .incomplete, .filledWithErrors: break - case .filledWithErrors: - showErrorsAlert = true case .solved: hasSolved = true onComplete?(false) @@ -1029,13 +1027,9 @@ private struct PuzzleLifecycleModifier: ViewModifier { .onChange(of: session.game.completionState) { _, newValue in onObservedCompletionStateChanged(newValue) } - .onAppear { - session.onCompletionStateChanged = { newValue in - onLocalCompletionStateChanged(newValue) - } - } - .onDisappear { - session.onCompletionStateChanged = nil + .onChange(of: session.localCompletionEvent) { _, newValue in + guard let newValue else { return } + onLocalCompletionStateChanged(newValue.state) } } } diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -101,6 +101,31 @@ struct PlayerSessionNavigationTests { #expect(session.currentClue()?.number == firstAcross.number) } + @Test("Overwriting a full wrong grid publishes a fresh error completion event") + func overwriteFullWrongGridPublishesFreshErrorEvent() throws { + let session = try makeNavigationSession() + + let letters = [ + (0, 0, "A"), (0, 1, "B"), (0, 2, "C"), + (1, 0, "D"), (1, 2, "E"), + (2, 0, "F"), (2, 1, "G"), (2, 2, "Z") + ] + for (row, col, letter) in letters { + session.select(row: row, col: col) + session.enter(letter) + } + let firstEvent = try #require(session.localCompletionEvent) + + session.select(row: 2, col: 2) + session.enter("Y") + let secondEvent = try #require(session.localCompletionEvent) + + #expect(session.game.completionState == .filledWithErrors) + #expect(firstEvent.state == .filledWithErrors) + #expect(secondEvent.state == .filledWithErrors) + #expect(secondEvent.sequence == firstEvent.sequence + 1) + } + @Test("Backspace from first down moves to final across end") func backspaceFromFirstDownMovesToFinalAcrossEnd() throws { let session = try makeNavigationSession()