crossmate

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

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:
MCrossmate/Persistence/GameStore.swift | 5++++-
MCrossmate/Services/AppServices.swift | 9+++++++++
MTests/Unit/GameStoreUnseenMovesTests.swift | 28++++++++++++++++++++++++++++
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()