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:
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)
+ }
+}