commit 8920e3c978b261f4c8f7363a62db11fbb892195c
parent 0ab0b0d7504b19961982ff8df24ec961dd0a0109
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 04:19:45 +0900
Improve notifications
Completed shared games no longer count as having unseen moves, in either the
per-row dot or the icon badge. In addition, notification authorization is now
requested at share creation and share acceptance, not just when an
already-shared puzzle is opened.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
3 files changed, 41 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -110,6 +110,7 @@ struct GameSummary: Identifiable, Equatable {
self.isAccessRevoked = entity.isAccessRevoked
self.hasUnseenOtherMoves = Self.computeHasUnseen(
isShared: self.isShared,
+ completedAt: entity.completedAt,
latest: entity.latestOtherMoveAt,
lastSeen: entity.lastSeenOtherMoveAt
)
@@ -117,10 +118,11 @@ struct GameSummary: Identifiable, Equatable {
fileprivate static func computeHasUnseen(
isShared: Bool,
+ completedAt: Date?,
latest: Date?,
lastSeen: Date?
) -> Bool {
- guard isShared, let latest else { return false }
+ guard isShared, completedAt == nil, let latest else { return false }
guard let lastSeen else { return true }
return latest > lastSeen
}
@@ -345,6 +347,7 @@ final class GameStore {
request.resultType = .countResultType
request.predicate = NSPredicate(
format: "(databaseScope == 1 OR ckShareRecordName != nil) "
+ + "AND completedAt == nil "
+ "AND latestOtherMoveAt != nil "
+ "AND (lastSeenOtherMoveAt == nil OR latestOtherMoveAt > lastSeenOtherMoveAt)"
)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -153,6 +153,11 @@ final class AppServices {
)
self.shareController.onShareSaved = { [weak store] gameID in
store?.markShared(gameID: gameID)
+ // Register the app for notifications now that the user has chosen
+ // to collaborate. Surfaces the app in Settings > Notifications and
+ // makes the icon-badge permission available before any inbound
+ // moves can arrive.
+ Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
}
self.playerSelectionPublisher = PlayerSelectionPublisher(
persistence: persistence,
@@ -237,6 +242,10 @@ final class AppServices {
cloudService.onShareJoined = { [weak self] gameID in
guard let self else { return }
+ // Register the app for notifications now that the user has joined
+ // a collaboration. Mirrors the owner path in `onShareSaved` so the
+ // app is in Settings > Notifications before any inbound moves.
+ await AppDelegate.requestNotificationAuthorizationIfNeeded()
guard self.preferences.isICloudSyncEnabled,
let authorID = self.identity.currentID
else { return }
diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift
@@ -176,6 +176,34 @@ struct GameStoreUnseenMovesTests {
#expect(store.unseenOtherMovesGameCount() == 0)
}
+ @Test("Completed shared games do not show as unseen even with later other-author moves")
+ func completedSharedGameSuppressesUnseen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+
+ let (entity, gameID) = try makeSharedGame(in: ctx)
+ entity.completedAt = Date(timeIntervalSinceNow: -100)
+ try ctx.save()
+
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: Date(),
+ in: ctx
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnseenOtherMoves)
+ #expect(store.unseenOtherMovesGameCount() == 0)
+ }
+
@Test("Opening a stale CmVer game reparses source and records current CmVer")
func openingStaleCmVerGameReparsesSource() throws {
let persistence = makeTestPersistence()