crossmate

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

commit 646cb9ffcd01f9d2a9a7fc7e69b1044de5408b26
parent 34f0d1b34a840e935be8e57da86f4c64d1ec3122
Author: Michael Camilleri <[email protected]>
Date:   Thu, 30 Apr 2026 13:17:41 +0900

Show unread badge for unseen shared puzzle changes

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 2++
MCrossmate/Persistence/GameStore.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 10++++++----
MCrossmate/Views/GameListView.swift | 15+++++++++++++++
ATests/Unit/GameStoreUnseenMovesTests.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 186 insertions(+), 4 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */; }; + 91E64D507D3ED109F9544133 /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F84030FF54D1F492EB091BB /* GameStoreUnseenMovesTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; @@ -96,6 +97,7 @@ 0B73A791FD061430AE286E11 /* morning.xd */ = {isa = PBXFileReference; path = morning.xd; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; + 0F84030FF54D1F492EB091BB /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; }; @@ -212,6 +214,7 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */, 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */, + 0F84030FF54D1F492EB091BB /* GameStoreUnseenMovesTests.swift */, BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */, 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, @@ -462,6 +465,7 @@ 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */, + 91E64D507D3ED109F9544133 /* GameStoreUnseenMovesTests.swift in Sources */, 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */, 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -12,7 +12,9 @@ <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="lamportHighWater" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="lastSeenOtherMoveLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="latestOtherMoveLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -29,6 +29,7 @@ struct GameSummary: Identifiable, Equatable { /// a share (participant, `databaseScope == 1`). let isShared: Bool let isAccessRevoked: Bool + let hasUnseenOtherMoves: Bool init?(entity: GameEntity) { guard let id = entity.id, @@ -71,6 +72,8 @@ struct GameSummary: Identifiable, Equatable { self.isOwned = entity.databaseScope == 0 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 self.isAccessRevoked = entity.isAccessRevoked + self.hasUnseenOtherMoves = self.isShared + && entity.latestOtherMoveLamport > entity.lastSeenOtherMoveLamport } } @@ -179,6 +182,37 @@ final class GameStore { } } + /// Records the newest synced move authored by someone other than the + /// current iCloud user. The list badge is local UI state, not CloudKit + /// state, so it lives on `GameEntity` and is advanced from the sync + /// callback after incoming moves have already been persisted. + func noteIncomingOtherMoves(_ moves: [Move], currentAuthorID: String?) { + guard let currentAuthorID, !moves.isEmpty else { return } + + var latestByGame: [UUID: Int64] = [:] + for move in moves { + guard let authorID = move.authorID, authorID != currentAuthorID else { continue } + latestByGame[move.gameID] = max(latestByGame[move.gameID] ?? 0, move.lamport) + } + guard !latestByGame.isEmpty else { return } + + for (gameID, latestLamport) in latestByGame { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? context.fetch(req).first else { continue } + + entity.latestOtherMoveLamport = max(entity.latestOtherMoveLamport, latestLamport) + if currentEntity?.id == gameID { + entity.lastSeenOtherMoveLamport = entity.latestOtherMoveLamport + } + } + + if context.hasChanges { + try? context.save() + } + } + /// Checks each game in `gameIDs` and writes a `SnapshotEntity` if the /// game is complete (has `completedAt` set) or has 200 or more moves not /// yet covered by an existing snapshot. Moves folded into a local @@ -321,6 +355,7 @@ final class GameStore { currentGame = game currentMutator = mutator currentEntity = entity + markOtherMovesSeen(for: entity) return (game, mutator) } @@ -486,6 +521,7 @@ final class GameStore { currentGame = game currentMutator = mutator currentEntity = entity + markOtherMovesSeen(for: entity) return (game, mutator) } @@ -499,6 +535,14 @@ final class GameStore { return try context.fetch(request).first } + private func markOtherMovesSeen(for entity: GameEntity) { + let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 + guard isShared, + entity.lastSeenOtherMoveLamport < entity.latestOtherMoveLamport else { return } + entity.lastSeenOtherMoveLamport = entity.latestOtherMoveLamport + try? context.save() + } + private func seedFromSample() throws -> (GameEntity, Puzzle) { guard let url = Bundle.main.url(forResource: "sample", withExtension: "xd") else { throw LoadError.sampleResourceMissing diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -118,10 +118,12 @@ final class AppServices { syncMonitor.note(message) } - await syncEngine.setOnRemoteMoves { [store] moves in - guard let currentID = store.currentEntity?.id, - moves.contains(where: { $0.gameID == currentID }) else { return } - store.refreshCurrentGame() + await syncEngine.setOnRemoteMoves { [store, identity] moves in + store.noteIncomingOtherMoves(moves, currentAuthorID: identity.currentID) + if let currentID = store.currentEntity?.id, + moves.contains(where: { $0.gameID == currentID }) { + store.refreshCurrentGame() + } } await syncEngine.setOnSessionPings { [weak self] pings in diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -179,12 +179,27 @@ private struct GameRowView: View { @State private var isShowingShareSheet = false var body: some View { + let showsUnseenBadge = game.hasUnseenOtherMoves + HStack(spacing: 12) { GridThumbnailView( width: game.gridWidth, height: game.gridHeight, cells: game.thumbnailCells ) + .overlay(alignment: .topTrailing) { + if showsUnseenBadge { + Circle() + .fill(.red) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(.background, lineWidth: 2) + ) + .offset(x: 5, y: -5) + .accessibilityLabel("Unseen changes") + } + } VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(game.title) diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -0,0 +1,115 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("GameStore unseen moves", .serialized) +@MainActor +struct GameStoreUnseenMovesTests { + @Test("Other-author moves mark a shared game unread") + func otherAuthorMoveMarksSharedGameUnread() throws { + let (store, gameID) = try makeStoreWithSharedGame() + + store.noteIncomingOtherMoves([ + Move( + gameID: gameID, + lamport: 3, + row: 0, + col: 0, + letter: "A", + markKind: 0, + checkedWrong: false, + authorID: "other", + createdAt: Date() + ) + ], currentAuthorID: "me") + + let entity = try #require(try fetchGame(store: store, gameID: gameID)) + let summary = try #require(GameSummary(entity: entity)) + #expect(entity.latestOtherMoveLamport == 3) + #expect(entity.lastSeenOtherMoveLamport == 0) + #expect(summary.hasUnseenOtherMoves) + } + + @Test("Own moves do not mark a shared game unread") + func ownMoveDoesNotMarkSharedGameUnread() throws { + let (store, gameID) = try makeStoreWithSharedGame() + + store.noteIncomingOtherMoves([ + Move( + gameID: gameID, + lamport: 4, + row: 0, + col: 0, + letter: "A", + markKind: 0, + checkedWrong: false, + authorID: "me", + createdAt: Date() + ) + ], currentAuthorID: "me") + + let entity = try #require(try fetchGame(store: store, gameID: gameID)) + let summary = try #require(GameSummary(entity: entity)) + #expect(entity.latestOtherMoveLamport == 0) + #expect(!summary.hasUnseenOtherMoves) + } + + @Test("Opening a game marks other-author moves seen") + func openingGameMarksOtherMovesSeen() throws { + let (store, gameID) = try makeStoreWithSharedGame() + let entity = try #require(try fetchGame(store: store, gameID: gameID)) + entity.latestOtherMoveLamport = 5 + try store.persistence.viewContext.save() + + _ = try store.loadGame(id: gameID) + + #expect(entity.lastSeenOtherMoveLamport == 5) + let summary = try #require(GameSummary(entity: entity)) + #expect(!summary.hasUnseenOtherMoves) + } + + private func makeStoreWithSharedGame() throws -> (GameStore, UUID) { + let persistence = makeTestPersistence() + let store = GameStore(persistence: persistence) + let context = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = "Shared Test" + entity.puzzleSource = Self.source + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + entity.ckShareRecordName = "share-\(gameID.uuidString)" + entity.databaseScope = 0 + try context.save() + return (store, gameID) + } + + private func fetchGame(store: GameStore, gameID: UUID) throws -> GameEntity? { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + return try store.persistence.viewContext.fetch(request).first + } + + private static let source = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ +}