crossmate

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

commit 0ab0b0d7504b19961982ff8df24ec961dd0a0109
parent bc1bab42ca39b4ea47e7316a0856d6ec76cda61a
Author: Michael Camilleri <[email protected]>
Date:   Fri, 15 May 2026 03:36:09 +0900

Show updated-game count on the app icon

The library list already marks shared games with unseen other-author moves
via a per-row dot. The same signal now also drives the app icon badge so the
count of updated games is visible from the home screen.

GameStore exposes an unseenOtherMovesGameCount count fetch using the same
heuristic as the per-row dot, plus an onUnseenOtherMovesChanged callback fired
from the points that can move the count: inbound Moves merged into a game,
markOtherMovesSeen catching lastSeen up on game open, local and remote game
removal, and the diagnostics reset.

AppServices wires the callback to a refreshAppBadge helper that calls
UNUserNotificationCenter.setBadgeCount with the current tally, and runs once
at the end of start(appDelegate:) so the badge reflects state at launch. The
notification authorization prompt now also requests .badge so iOS will
render the value for newly authorized users.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Persistence/GameStore.swift | 34++++++++++++++++++++++++++++++----
MCrossmate/Services/AppServices.swift | 19+++++++++++++++++++
MTests/Unit/GameStoreUnseenMovesTests.swift | 34++++++++++++++++++++++++++++++++++
4 files changed, 84 insertions(+), 5 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -131,7 +131,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() guard settings.authorizationStatus == .notDetermined else { return } - _ = try? await center.requestAuthorization(options: [.alert, .sound]) + _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) } func application( diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -228,6 +228,12 @@ final class GameStore { /// changes and needs to be re-pushed. private let onGameUpdated: (String) -> Void + /// Fires when the count of shared games with unseen other-author moves + /// may have changed (inbound moves merged, a game opened, a game + /// deleted). Consumers refresh the app-icon badge from here. + @ObservationIgnored + var onUnseenOtherMovesChanged: (() -> Void)? + init( persistence: PersistenceController, movesUpdater: MovesUpdater, @@ -328,6 +334,21 @@ final class GameStore { if context.hasChanges { try? context.save() } + onUnseenOtherMovesChanged?() + } + + /// Number of shared games with unseen other-author moves — the same + /// `hasUnseenOtherMoves` heuristic the library list uses, aggregated as + /// a count for the app-icon badge. + func unseenOtherMovesGameCount() -> Int { + let request = NSFetchRequest<NSNumber>(entityName: "GameEntity") + request.resultType = .countResultType + request.predicate = NSPredicate( + format: "(databaseScope == 1 OR ckShareRecordName != nil) " + + "AND latestOtherMoveAt != nil " + + "AND (lastSeenOtherMoveAt == nil OR latestOtherMoveAt > lastSeenOtherMoveAt)" + ) + return (try? context.count(for: request)) ?? 0 } // MARK: - Load a specific game @@ -450,6 +471,7 @@ final class GameStore { context.delete(entity) try context.save() onGameDeleted(deletion) + onUnseenOtherMovesChanged?() } // MARK: - Resign a game @@ -506,6 +528,7 @@ final class GameStore { currentGame = nil currentMutator = nil currentEntity = nil + onUnseenOtherMovesChanged?() } // MARK: - Legacy convenience @@ -553,6 +576,7 @@ final class GameStore { if (entity.lastSeenOtherMoveAt ?? .distantPast) < latest { entity.lastSeenOtherMoveAt = latest try? context.save() + onUnseenOtherMovesChanged?() } } @@ -718,10 +742,12 @@ final class GameStore { /// the open puzzle is the one that just disappeared, so the UI doesn't /// dereference a deleted managed object. func handleRemoteRemoval(gameID: UUID) { - guard currentEntity?.id == gameID else { return } - currentGame = nil - currentMutator = nil - currentEntity = nil + if currentEntity?.id == gameID { + currentGame = nil + currentMutator = nil + currentEntity = nil + } + onUnseenOtherMovesChanged?() } /// Flips the active game's mutator to shared after `ShareController` diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -178,6 +178,12 @@ final class AppServices { nytAuth.loadStoredSession() driveMonitor.start() + store.onUnseenOtherMovesChanged = { [weak self] in + guard let self else { return } + Task { await self.refreshAppBadge() } + } + await refreshAppBadge() + appDelegate.onRemoteNotification = { summary, scope, isBackground in await self.handleRemoteNotification( summary: summary, @@ -1000,6 +1006,19 @@ final class AppServices { syncMonitor.updateSnapshot(snapshot) } + /// Sets the app icon badge to the number of shared games with unseen + /// other-author moves — the same `hasUnseenOtherMoves` signal that drives + /// the per-row dot in the library list. Silently no-ops when the user + /// hasn't granted badge permission; iOS just won't render the value. + func refreshAppBadge() async { + let count = store.unseenOtherMovesGameCount() + do { + try await UNUserNotificationCenter.current().setBadgeCount(count) + } catch { + syncMonitor.note("app badge update failed: \(error.localizedDescription)") + } + } + /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can /// drive the exact same closure that production wires up — keeps the /// colour-cleanup branch from drifting silently. diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -142,6 +142,40 @@ struct GameStoreUnseenMovesTests { #expect(!summary.hasUnseenOtherMoves) } + @Test("unseenOtherMovesGameCount tallies shared games with pending other-author moves") + func unseenOtherMovesGameCountAcrossGames() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + + // Unseen: shared game with other-author moves and no lastSeen. + let (gameA, gameAID) = try makeSharedGame(in: ctx) + try addMovesRow( + for: gameA, + gameID: gameAID, + authorID: Self.otherAuthorID, + updatedAt: Date(timeIntervalSinceNow: -20), + in: ctx + ) + store.noteIncomingMovesUpdate( + gameIDs: [gameAID], + currentAuthorID: Self.localAuthorID + ) + + // Seen: shared game whose lastSeen catches up to latest. + let (gameB, gameBID) = try makeSharedGame(in: ctx) + let seenLatest = Date(timeIntervalSinceNow: -30) + gameB.latestOtherMoveAt = seenLatest + gameB.lastSeenOtherMoveAt = seenLatest + try ctx.save() + + #expect(store.unseenOtherMovesGameCount() == 1) + + // Opening the unseen game advances lastSeen and clears the badge tally. + _ = try store.loadGame(id: gameAID) + #expect(store.unseenOtherMovesGameCount() == 0) + } + @Test("Opening a stale CmVer game reparses source and records current CmVer") func openingStaleCmVerGameReparsesSource() throws { let persistence = makeTestPersistence()