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