commit c943ffca7090dbdf1cb1336ba68f66165134b594
parent 58154ad2ca30e3739418d1325c3ec1eaa1ef36b7
Author: Michael Camilleri <[email protected]>
Date: Wed, 17 Jun 2026 15:19:27 +0900
Show shared participants on in-progress Game List rows
This commit adds a participant colour strip to shared games that are
still in progress. The strip sits under the 60 pt thumbnail and uses the
same cursor-track fill as the puzzle grid, opening a lightweight players
popover so the user can see which remote players have actually joined
without changing completed-game rows.
Participant summaries now come from the local Player and Moves records,
exclude the local author, honour friend nicknames, and reserve the user's
own colour when assigning stable colours to remote players. GameSummary
and PlayerRoster share that derivation, so the Game List and in-puzzle
player roster stay aligned without creating live rosters for every list
row.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
9 files changed, 426 insertions(+), 131 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -183,6 +183,7 @@
E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; };
E454051BA4797000C8AD2B48 /* MovesCodecLegacyDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B33C21324E1474BCC126AA0 /* MovesCodecLegacyDecodeTests.swift */; };
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; };
+ E6A13F8736ABF41F6346E301 /* ParticipantSummaries.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */; };
E81F92AAB2968997C3D68809 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; };
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; };
EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */; };
@@ -424,6 +425,7 @@
F9B757D86362CD6F0500E9CB /* CustomButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomButtons.swift; sourceTree = "<group>"; };
FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushRequestAuthenticator.swift; sourceTree = "<group>"; };
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; };
+ FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantSummaries.swift; sourceTree = "<group>"; };
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; };
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -567,6 +569,7 @@
B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */,
E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */,
7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */,
+ FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
3292748EAE27B608C769D393 /* PlayerRoster.swift */,
@@ -1062,6 +1065,7 @@
36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */,
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */,
CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */,
+ E6A13F8736ABF41F6346E301 /* ParticipantSummaries.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -458,6 +458,7 @@ struct RootView: View {
GameListView(
store: services.store,
shareController: services.shareController,
+ authorIdentity: services.identity,
onRefresh: { await services.refreshLibrary() },
onAppear: { await services.gameListAppeared() },
onDisappear: { services.gameListDisappeared() },
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -0,0 +1,47 @@
+import CloudKit
+import Foundation
+
+struct GameParticipantSummary: Identifiable, Equatable {
+ let authorID: String
+ let name: String
+ let color: PlayerColor
+ var id: String { authorID }
+}
+
+enum ParticipantSummaries {
+ static func remoteParticipants(
+ namesByAuthor: [String: String],
+ moveAuthorIDs: [String],
+ nicknamesByAuthor: [String: String],
+ localAuthorID: String?,
+ localColor: PlayerColor,
+ additionalAuthorIDs: [String] = []
+ ) -> [GameParticipantSummary] {
+ var authorIDs = Set(namesByAuthor.keys)
+ authorIDs.formUnion(moveAuthorIDs)
+ authorIDs.formUnion(additionalAuthorIDs)
+ authorIDs.remove(CKCurrentUserDefaultName)
+ authorIDs.remove("")
+ if let localAuthorID, !localAuthorID.isEmpty {
+ authorIDs.remove(localAuthorID)
+ }
+
+ var taken: Set<String> = [localColor.id]
+ var summaries: [GameParticipantSummary] = []
+ for authorID in authorIDs.sorted() {
+ let name = nicknamesByAuthor[authorID]
+ ?? namesByAuthor[authorID]
+ ?? "Waiting for player..."
+ let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: taken)
+ taken.insert(color.id)
+ summaries.append(GameParticipantSummary(
+ authorID: authorID,
+ name: name,
+ color: color
+ ))
+ }
+ return summaries.sorted {
+ $0.name == $1.name ? $0.authorID < $1.authorID : $0.name < $1.name
+ }
+ }
+}
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -316,27 +316,15 @@ final class PlayerRoster {
fetched: FetchedRoster,
share: CKShare?
) {
- // Collect all remote participant authorIDs.
- var otherAuthorIDs = Set<String>()
- for key in fetched.namesMap.keys
- where key != localAuthorID
- && key != CKCurrentUserDefaultName
- && !key.isEmpty {
- otherAuthorIDs.insert(key)
- }
+ var shareAuthorIDs: [String] = []
if let share {
for participant in share.participants {
guard participant.acceptanceStatus == .accepted,
- let recordName = participant.userIdentity.userRecordID?.recordName,
- recordName != localAuthorID,
- recordName != CKCurrentUserDefaultName
+ let recordName = participant.userIdentity.userRecordID?.recordName
else { continue }
- otherAuthorIDs.insert(recordName)
+ shareAuthorIDs.append(recordName)
}
}
- for authorID in fetched.moveAuthorIDs where authorID != CKCurrentUserDefaultName {
- otherAuthorIDs.insert(authorID)
- }
// Assign each friend a deterministic colour. Walking the participants
// in sorted-authorID order and threading a running `taken` set —
@@ -346,20 +334,20 @@ final class PlayerRoster {
// same colour for a friend without any persisted or synced mapping;
// the friend also keeps that colour across games (only a lower-sorted
// collaborator colliding with them in a given game can bump it).
- var remoteEntries: [Entry] = []
- var taken: Set<String> = [preferences.color.id]
- for authorID in otherAuthorIDs.sorted() {
- let name = resolveName(
- authorID: authorID,
- namesMap: fetched.namesMap,
- nicknames: fetched.nicknamesByAuthor
+ let remoteEntries = ParticipantSummaries.remoteParticipants(
+ namesByAuthor: fetched.namesMap,
+ moveAuthorIDs: fetched.moveAuthorIDs,
+ nicknamesByAuthor: fetched.nicknamesByAuthor,
+ localAuthorID: localAuthorID,
+ localColor: preferences.color,
+ additionalAuthorIDs: shareAuthorIDs
+ ).map { participant in
+ Entry(
+ authorID: participant.authorID,
+ name: participant.name,
+ color: participant.color,
+ isLocal: false
)
- let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: taken)
- taken.insert(color.id)
- remoteEntries.append(Entry(authorID: authorID, name: name, color: color, isLocal: false))
- }
- remoteEntries.sort {
- $0.name == $1.name ? $0.authorID < $1.authorID : $0.name < $1.name
}
let localEntry = Entry(
@@ -508,18 +496,4 @@ final class PlayerRoster {
return nil
}
- private func resolveName(
- authorID: String,
- namesMap: [String: String],
- nicknames: [String: String]
- ) -> String {
- // A nickname the user assigned via Rename wins outright — it's their
- // private label for the peer and never follows the peer's renames.
- if let nickname = nicknames[authorID] { return nickname }
- // The game-specific Player record is authoritative for display names.
- // CKShare metadata can arrive earlier, but it may expose an unrelated
- // contact/iCloud name and then visibly rename the row a moment later.
- if let name = namesMap[authorID], !name.isEmpty { return name }
- return "Waiting for player..."
- }
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -32,8 +32,13 @@ struct GameSummary: Identifiable, Equatable {
let isShared: Bool
let isAccessRevoked: Bool
let hasUnreadOtherMoves: Bool
+ let participants: [GameParticipantSummary]
- init?(entity: GameEntity) {
+ init?(
+ entity: GameEntity,
+ localAuthorID: String? = nil,
+ localColor: PlayerColor = .blue
+ ) {
guard let id = entity.id else { return nil }
let width: Int
@@ -118,6 +123,11 @@ struct GameSummary: Identifiable, Equatable {
self.isOwned = entity.databaseScope == 0
self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
self.isAccessRevoked = entity.isAccessRevoked
+ self.participants = Self.computeParticipants(
+ entity: entity,
+ localAuthorID: localAuthorID,
+ localColor: localColor
+ )
self.hasUnreadOtherMoves = Self.computeHasUnread(
isShared: self.isShared,
completedAt: entity.completedAt,
@@ -140,6 +150,57 @@ struct GameSummary: Identifiable, Equatable {
guard let readThrough else { return true }
return latest > readThrough
}
+
+ private static func computeParticipants(
+ entity: GameEntity,
+ localAuthorID: String?,
+ localColor: PlayerColor
+ ) -> [GameParticipantSummary] {
+ guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else { return [] }
+
+ var namesByAuthor: [String: String] = [:]
+ let playerEntities = (entity.players as? Set<PlayerEntity>) ?? []
+ for player in playerEntities {
+ guard let authorID = player.authorID, !authorID.isEmpty else { continue }
+ if let name = player.name?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ namesByAuthor[authorID] = name
+ }
+ }
+
+ let movesEntities = (entity.moves as? Set<MovesEntity>) ?? []
+ var moveAuthorIDs: [String] = []
+ for moves in movesEntities {
+ guard let authorID = moves.authorID, !authorID.isEmpty else { continue }
+ moveAuthorIDs.append(authorID)
+ }
+
+ return ParticipantSummaries.remoteParticipants(
+ namesByAuthor: namesByAuthor,
+ moveAuthorIDs: moveAuthorIDs,
+ nicknamesByAuthor: friendNicknames(in: entity.managedObjectContext),
+ localAuthorID: localAuthorID,
+ localColor: localColor
+ )
+ }
+
+ private static func friendNicknames(in context: NSManagedObjectContext?) -> [String: String] {
+ guard let context else { return [:] }
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(
+ format: "isBlocked == NO AND nickname != nil AND nickname != %@", ""
+ )
+ let friends = (try? context.fetch(req)) ?? []
+ var nicknames: [String: String] = [:]
+ for friend in friends {
+ guard let authorID = friend.authorID, !authorID.isEmpty,
+ let nickname = friend.nickname?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !nickname.isEmpty
+ else { continue }
+ nicknames[authorID] = nickname
+ }
+ return nicknames
+ }
}
/// CloudKit routing metadata captured before a game row is deleted locally.
@@ -180,10 +241,19 @@ final class GameSummaryCache {
let gridWidth: Int16
let gridHeight: Int16
let blockMask: Data?
+ let localAuthorID: String?
+ let localColorID: String
+ let playersSignature: [String]
+ let movesAuthorIDs: [String]
+ let nicknamesSignature: [String]
}
private var entries: [NSManagedObjectID: (key: Key, summary: GameSummary)] = [:]
- func summary(for entity: GameEntity) -> GameSummary? {
+ func summary(
+ for entity: GameEntity,
+ localAuthorID: String? = nil,
+ localColor: PlayerColor = .blue
+ ) -> GameSummary? {
let key = Key(
updatedAt: entity.updatedAt,
completedAt: entity.completedAt,
@@ -197,15 +267,51 @@ final class GameSummaryCache {
puzzleDate: entity.cachedPuzzleDate,
gridWidth: entity.gridWidth,
gridHeight: entity.gridHeight,
- blockMask: entity.blockMask
+ blockMask: entity.blockMask,
+ localAuthorID: localAuthorID,
+ localColorID: localColor.id,
+ playersSignature: Self.playersSignature(for: entity),
+ movesAuthorIDs: Self.movesAuthorIDs(for: entity),
+ nicknamesSignature: Self.nicknamesSignature(in: entity.managedObjectContext)
)
if let hit = entries[entity.objectID], hit.key == key {
return hit.summary
}
- guard let fresh = GameSummary(entity: entity) else { return nil }
+ guard let fresh = GameSummary(
+ entity: entity,
+ localAuthorID: localAuthorID,
+ localColor: localColor
+ ) else { return nil }
entries[entity.objectID] = (key, fresh)
return fresh
}
+
+ private static func playersSignature(for entity: GameEntity) -> [String] {
+ let players = (entity.players as? Set<PlayerEntity>) ?? []
+ return players.map { player in
+ "\(player.authorID ?? "")|\(player.name ?? "")|\(player.updatedAt?.timeIntervalSinceReferenceDate ?? 0)"
+ }
+ .sorted()
+ }
+
+ private static func movesAuthorIDs(for entity: GameEntity) -> [String] {
+ let moves = (entity.moves as? Set<MovesEntity>) ?? []
+ return Array(Set(moves.compactMap { $0.authorID })).sorted()
+ }
+
+ private static func nicknamesSignature(in context: NSManagedObjectContext?) -> [String] {
+ guard let context else { return [] }
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(
+ format: "isBlocked == NO AND nickname != nil AND nickname != %@", ""
+ )
+ let friends = (try? context.fetch(req)) ?? []
+ return friends.compactMap { friend in
+ guard let authorID = friend.authorID, !authorID.isEmpty else { return nil }
+ return "\(authorID)|\(friend.nickname ?? "")"
+ }
+ .sorted()
+ }
}
extension GameEntity {
diff --git a/Crossmate/Views/GameList/GameCardView.swift b/Crossmate/Views/GameList/GameCardView.swift
@@ -7,11 +7,8 @@ enum CardMetrics {
static let cornerRadius: CGFloat = 12
}
-/// Tappable card used in the iPad grid layout. The whole card is one
-/// `Button` (so the pressed-state highlight covers the full card), and the
-/// overflow `Menu` is layered as an `.overlay` rather than nested inside the
-/// button — keeping them siblings means tapping the ellipsis opens the menu
-/// instead of also firing the navigation action.
+/// Tappable card used in the iPad grid layout. The card handles plain taps,
+/// while the participant swatch and overflow menu remain independent controls.
struct GameCardView: View {
let game: GameSummary
let shareController: ShareController
@@ -25,58 +22,45 @@ struct GameCardView: View {
var body: some View {
let showsUnreadBadge = game.hasUnreadOtherMoves
- Button(action: onResume) {
- HStack(spacing: 12) {
- GridThumbnailView(
- width: game.gridWidth,
- height: game.gridHeight,
- cells: game.thumbnailCells
- )
- .overlay(alignment: .topTrailing) {
- if showsUnreadBadge {
- Circle()
- .fill(.red)
- .frame(width: 14, height: 14)
- .overlay(
- Circle()
- .stroke(.background, lineWidth: 2)
- )
- .offset(x: 5, y: -5)
- .accessibilityLabel("Unseen changes")
+ HStack(spacing: 12) {
+ GameListThumbnailView(
+ game: game,
+ showsUnreadBadge: showsUnreadBadge
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Text(game.title)
+ .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .truncationMode(.tail)
+ if game.isShared {
+ SharedGameSymbol()
}
}
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text(game.title)
- .font((usesRoomierType ? Font.headline : .subheadline).weight(.semibold))
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- .truncationMode(.tail)
- if game.isShared {
- Image(systemName: "person.2.fill")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- GameMetadataView(
- puzzleDate: game.puzzleDate,
- publisher: game.publisher,
- usesRoomierType: usesRoomierType
- )
- if let date = game.updatedAt {
- LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
- }
+ GameMetadataView(
+ puzzleDate: game.puzzleDate,
+ publisher: game.publisher,
+ usesRoomierType: usesRoomierType
+ )
+ if let date = game.updatedAt {
+ LastUpdatedView(date: date, usesRoomierType: usesRoomierType)
}
- Spacer(minLength: 0)
- // Reserve room for the overflow menu, which is layered as an
- // overlay so its taps don't fall through to this button.
- Color.clear.frame(width: 32, height: 32)
}
- .padding(12)
- .frame(maxWidth: .infinity)
- .frame(height: CardMetrics.height)
+ Spacer(minLength: 0)
+ // Reserve room for the overflow menu, which is layered as an
+ // overlay so its taps don't fall through to the card tap.
+ Color.clear.frame(width: 32, height: 32)
}
- .buttonStyle(CardButtonStyle())
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .frame(height: CardMetrics.height)
+ .background(
+ Color(.secondarySystemGroupedBackground),
+ in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius))
+ .onTapGesture(perform: onResume)
.overlay(alignment: .trailing) {
GameOverflowMenu(
game: game,
@@ -98,20 +82,51 @@ struct GameCardView: View {
}
}
-private struct CardButtonStyle: ButtonStyle {
- func makeBody(configuration: Configuration) -> some View {
- configuration.label
- .background(
- Color(.secondarySystemGroupedBackground),
- in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+struct GameListThumbnailView: View {
+ let game: GameSummary
+ let showsUnreadBadge: Bool
+
+ private var showsParticipantStrip: Bool {
+ game.isShared && game.completedAt == nil
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ GridThumbnailView(
+ width: game.gridWidth,
+ height: game.gridHeight,
+ cells: game.thumbnailCells,
+ size: 60
)
- .overlay {
- if configuration.isPressed {
- RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
- .fill(Color.primary.opacity(0.06))
+ .overlay(alignment: .topTrailing) {
+ if showsUnreadBadge {
+ Circle()
+ .fill(.red)
+ .frame(width: 14, height: 14)
+ .overlay(
+ Circle()
+ .stroke(.background, lineWidth: 2)
+ )
+ .offset(x: 5, y: -5)
+ .accessibilityLabel("Unseen changes")
}
}
- .contentShape(RoundedRectangle(cornerRadius: CardMetrics.cornerRadius))
+
+ if showsParticipantStrip {
+ SharedParticipantsButton(participants: game.participants)
+ .frame(width: 60)
+ }
+ }
+ .frame(width: 60)
+ }
+}
+
+struct SharedGameSymbol: View {
+ var body: some View {
+ Image(systemName: "person.2.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Shared puzzle")
}
}
@@ -159,6 +174,100 @@ struct GameOverflowMenu: View {
}
}
+struct SharedParticipantsButton: View {
+ let participants: [GameParticipantSummary]
+ @State private var isShowingParticipants = false
+
+ var body: some View {
+ Button {
+ isShowingParticipants = true
+ } label: {
+ ParticipantColorStrip(participants: participants)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(accessibilityLabel)
+ .popover(isPresented: $isShowingParticipants) {
+ GameParticipantsPopover(participants: participants)
+ .presentationCompactAdaptation(.popover)
+ }
+ }
+
+ private var accessibilityLabel: String {
+ if participants.isEmpty {
+ return "Shared puzzle"
+ }
+ if participants.count == 1, let participant = participants.first {
+ return "Shared with \(participant.name)"
+ }
+ return "Shared with \(participants.count) players"
+ }
+}
+
+private struct ParticipantColorStrip: View {
+ let participants: [GameParticipantSummary]
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ HStack(spacing: 0) {
+ if participants.isEmpty {
+ Rectangle()
+ .fill(Color.secondary.opacity(0.35))
+ } else {
+ ForEach(participants) { participant in
+ Rectangle()
+ .fill(participant.color.selectionFill)
+ }
+ }
+ }
+ .frame(height: 6)
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .frame(height: 6)
+ .contentShape(Rectangle())
+ }
+}
+
+private struct GameParticipantsPopover: View {
+ let participants: [GameParticipantSummary]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("Players")
+ .font(.headline)
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 8)
+
+ if participants.isEmpty {
+ Text("Waiting for player...")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 16)
+ } else {
+ VStack(spacing: 0) {
+ ForEach(participants) { participant in
+ HStack(spacing: 10) {
+ Circle()
+ .fill(participant.color.selectionFill)
+ .frame(width: 14, height: 14)
+ Text(participant.name)
+ .font(.subheadline)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 9)
+ }
+ }
+ .padding(.bottom, 6)
+ }
+ }
+ .frame(minWidth: 220, idealWidth: 260)
+ }
+}
+
struct GameMetadataView: View {
let puzzleDate: Date?
let publisher: String?
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct GameListView: View {
let store: GameStore
let shareController: ShareController
+ let authorIdentity: AuthorIdentity
let onRefresh: () async -> Void
let onAppear: () async -> Void
let onDisappear: () -> Void
@@ -223,7 +224,13 @@ struct GameListView: View {
@ViewBuilder
private func content(usesRoomierType: Bool) -> some View {
- let summaries = games.compactMap { summaryCache.summary(for: $0) }
+ let summaries = games.compactMap {
+ summaryCache.summary(
+ for: $0,
+ localAuthorID: authorIdentity.currentID,
+ localColor: preferences.color
+ )
+ }
let inProgress = summaries
.filter { $0.completedAt == nil && !$0.isAccessRevoked }
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
diff --git a/Crossmate/Views/GameList/GameRowView.swift b/Crossmate/Views/GameList/GameRowView.swift
@@ -16,24 +16,10 @@ struct GameRowView: View {
let showsUnreadBadge = game.hasUnreadOtherMoves
HStack(spacing: 12) {
- GridThumbnailView(
- width: game.gridWidth,
- height: game.gridHeight,
- cells: game.thumbnailCells
+ GameListThumbnailView(
+ game: game,
+ showsUnreadBadge: showsUnreadBadge
)
- .overlay(alignment: .topTrailing) {
- if showsUnreadBadge {
- 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)
@@ -42,9 +28,7 @@ struct GameRowView: View {
.minimumScaleFactor(0.8)
.truncationMode(.tail)
if game.isShared {
- Image(systemName: "person.2.fill")
- .font(.caption)
- .foregroundStyle(.secondary)
+ SharedGameSymbol()
}
}
GameMetadataView(
@@ -76,4 +60,3 @@ struct GameRowView: View {
}
}
}
-
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -60,6 +60,34 @@ struct GameSummaryThumbnailTests {
cell.letter = letter
}
+ private func addPlayer(
+ authorID: String,
+ name: String,
+ to entity: GameEntity,
+ in ctx: NSManagedObjectContext
+ ) {
+ let player = PlayerEntity(context: ctx)
+ player.game = entity
+ player.authorID = authorID
+ player.name = name
+ player.ckRecordName = "player-\(authorID)"
+ player.updatedAt = Date()
+ }
+
+ private func addMoves(
+ authorID: String,
+ to entity: GameEntity,
+ in ctx: NSManagedObjectContext
+ ) {
+ let moves = MovesEntity(context: ctx)
+ moves.game = entity
+ moves.authorID = authorID
+ moves.deviceID = "device-\(authorID)"
+ moves.cells = Data()
+ moves.updatedAt = Date()
+ moves.ckRecordName = "moves-\(authorID)"
+ }
+
@Test("In-progress thumbnail mirrors the CellEntity cache")
func inProgressThumbnailMirrorsCache() throws {
let persistence = makeTestPersistence()
@@ -96,4 +124,40 @@ struct GameSummaryThumbnailTests {
.filled, .filled, .filled,
])
}
+
+ @Test("Shared summary lists remote participants with deterministic colours")
+ func sharedSummaryListsRemoteParticipants() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let entity = try makeGame(in: ctx)
+ entity.ckShareRecordName = "cloudkit.zoneshare"
+ addPlayer(authorID: "_Local", name: "Local", to: entity, in: ctx)
+ addPlayer(authorID: "_Alice", name: "Alice", to: entity, in: ctx)
+ addMoves(authorID: "_Bob", to: entity, in: ctx)
+
+ let friend = FriendEntity(context: ctx)
+ friend.authorID = "_Alice"
+ friend.createdAt = Date()
+ friend.databaseScope = 0
+ friend.displayName = "Alice"
+ friend.displayNameVersion = 0
+ friend.friendZoneName = "friend-zone"
+ friend.friendZoneOwnerName = "_Local"
+ friend.isBlocked = false
+ friend.nickname = "Ace"
+ friend.nicknameVersion = 1
+ friend.pairKey = "pair"
+ try ctx.save()
+
+ let summary = try #require(GameSummary(
+ entity: entity,
+ localAuthorID: "_Local",
+ localColor: .blue
+ ))
+
+ #expect(summary.participants.map(\.authorID) == ["_Alice", "_Bob"])
+ #expect(summary.participants.map(\.name) == ["Ace", "Waiting for player..."])
+ #expect(!summary.participants.map(\.color.id).contains(PlayerColor.blue.id))
+ #expect(Set(summary.participants.map(\.color.id)).count == 2)
+ }
}