crossmate

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

commit 36031858801573ea8a1e5a129e0b1b09db594da4
parent 358f33254ddcbe6be90a7060e0380da6967df2d8
Author: Michael Camilleri <[email protected]>
Date:   Mon,  4 May 2026 09:36:53 +0900

Ensure notifications display during backgrounding

Prior to this commit, the gating logic for notifications would prevent
those appearing for a puzzle that was open when backgrounding occurred.
This commit changes that so that notifications do display.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 18++++++++++++++++--
MShared/NotificationState.swift | 9+++++++--
ATests/Unit/NotificationStateTests.swift | 32++++++++++++++++++++++++++++++++
4 files changed, 59 insertions(+), 4 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; + E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */; }; @@ -117,6 +118,7 @@ 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; }; 462CE0FD356F6137C9BFD30F /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = "<group>"; }; 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; + 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; }; 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; @@ -219,6 +221,7 @@ BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */, 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, + 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, @@ -473,6 +476,7 @@ 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, + E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -223,6 +223,7 @@ struct RootView: View { } .environment(services.preferences) .task { + NotificationState.setActivePuzzleID(nil) NotificationNavigationBroker.shared.onOpenGame = { gameID in UIApplication.shared.dismissPresentedViewControllers() navigationPath = NavigationPath() @@ -247,6 +248,7 @@ struct RootView: View { case .active: Task { await services.syncOnForeground() } case .background, .inactive: + NotificationState.setActivePuzzleID(nil) Task { await services.syncOnBackground() } @unknown default: break @@ -281,6 +283,7 @@ private struct PuzzleDisplayView: View { let services: AppServices @Environment(PlayerPreferences.self) private var preferences + @Environment(\.scenePhase) private var scenePhase @State private var session: PlayerSession? @State private var roster: PlayerRoster? @State private var loadError: String? @@ -311,7 +314,7 @@ private struct PuzzleDisplayView: View { await pollOpenSharedPuzzle() } .task(id: gameID) { - NotificationState.setActivePuzzleID(gameID) + updateActiveNotificationPuzzleID(for: scenePhase) do { let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession(game: game, mutator: mutator) @@ -333,8 +336,11 @@ private struct PuzzleDisplayView: View { else { return } Task { await activateSharing(for: session) } } + .onChange(of: scenePhase) { _, newPhase in + updateActiveNotificationPuzzleID(for: newPhase) + } .onDisappear { - NotificationState.setActivePuzzleID(nil) + NotificationState.clearActivePuzzleID(if: gameID) let presence = services.presencePublisher let moveBuffer = services.moveBuffer let exitedID = gameID @@ -345,6 +351,14 @@ private struct PuzzleDisplayView: View { } } + private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { + if phase == .active { + NotificationState.setActivePuzzleID(gameID) + } else { + NotificationState.clearActivePuzzleID(if: gameID) + } + } + /// Initialises shared-game state (roster, presence, name broadcast) for /// the open session. Called when the puzzle first appears as shared, and /// again if a previously-solo game becomes shared mid-session. diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -3,8 +3,8 @@ import Foundation /// Notification suppression state persisted via App Group UserDefaults. /// /// Two pieces of state are tracked: -/// - `activePuzzleID` — set by the app while the user is viewing a puzzle so -/// local notifications for that same puzzle can be skipped. +/// - `activePuzzleID` — set by the app while the user is viewing a puzzle in +/// the foreground so local notifications for that same puzzle can be skipped. /// - `shownByGame` — a `[gameID: Date]` map used to debounce repeat /// notifications. Once a SessionPing for game X has been shown, further /// pings for X within `dedupWindow` are suppressed. @@ -37,6 +37,11 @@ enum NotificationState { } } + static func clearActivePuzzleID(if id: UUID) { + guard activePuzzleID() == id else { return } + setActivePuzzleID(nil) + } + /// Returns true if a notification for `gameID` was shown within /// `dedupWindow`, or if the user is currently viewing it. static func shouldSuppress(gameID: UUID, now: Date = Date()) -> Bool { diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Notification state", .serialized) +struct NotificationStateTests { + @Test("Active puzzle suppresses only until it is cleared") + func activePuzzleSuppressionClears() { + let gameID = UUID() + NotificationState.setActivePuzzleID(nil) + + NotificationState.setActivePuzzleID(gameID) + #expect(NotificationState.shouldSuppress(gameID: gameID)) + + NotificationState.clearActivePuzzleID(if: gameID) + #expect(!NotificationState.shouldSuppress(gameID: gameID)) + } + + @Test("Clearing one puzzle does not clear another active puzzle") + func clearActivePuzzleRequiresMatchingID() { + let gameID = UUID() + let otherID = UUID() + NotificationState.setActivePuzzleID(nil) + + NotificationState.setActivePuzzleID(gameID) + NotificationState.clearActivePuzzleID(if: otherID) + + #expect(NotificationState.activePuzzleID() == gameID) + NotificationState.setActivePuzzleID(nil) + } +}