commit 8e48f14bf2d6346095d3b9e2b2e78d1249d189a2
parent 35251409520929a0b1344056ec1088e1e9856cac
Author: Michael Camilleri <[email protected]>
Date: Thu, 18 Jun 2026 06:01:56 +0900
Add user to player popover in the Game List
Diffstat:
5 files changed, 85 insertions(+), 18 deletions(-)
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -5,10 +5,39 @@ struct GameParticipantSummary: Identifiable, Equatable {
let authorID: String
let name: String
let color: PlayerColor
+ let isLocal: Bool
var id: String { authorID }
}
enum ParticipantSummaries {
+ static func allParticipants(
+ namesByAuthor: [String: String],
+ moveAuthorIDs: [String],
+ nicknamesByAuthor: [String: String],
+ localAuthorID: String?,
+ localName: String,
+ localColor: PlayerColor,
+ additionalAuthorIDs: [String] = []
+ ) -> [GameParticipantSummary] {
+ let remotes = remoteParticipants(
+ namesByAuthor: namesByAuthor,
+ moveAuthorIDs: moveAuthorIDs,
+ nicknamesByAuthor: nicknamesByAuthor,
+ localAuthorID: localAuthorID,
+ localColor: localColor,
+ additionalAuthorIDs: additionalAuthorIDs
+ )
+ guard let localAuthorID, !localAuthorID.isEmpty else { return remotes }
+ return [
+ GameParticipantSummary(
+ authorID: localAuthorID,
+ name: resolvedLocalName(localName),
+ color: localColor,
+ isLocal: true
+ ),
+ ] + remotes
+ }
+
static func remoteParticipants(
namesByAuthor: [String: String],
moveAuthorIDs: [String],
@@ -37,11 +66,16 @@ enum ParticipantSummaries {
summaries.append(GameParticipantSummary(
authorID: authorID,
name: name,
- color: color
+ color: color,
+ isLocal: false
))
}
return summaries.sorted {
$0.name == $1.name ? $0.authorID < $1.authorID : $0.name < $1.name
}
}
+
+ private static func resolvedLocalName(_ name: String) -> String {
+ name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Player" : name
+ }
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -33,10 +33,12 @@ struct GameSummary: Identifiable, Equatable {
let isAccessRevoked: Bool
let hasUnreadOtherMoves: Bool
let participants: [GameParticipantSummary]
+ let allParticipants: [GameParticipantSummary]
init?(
entity: GameEntity,
localAuthorID: String? = nil,
+ localName: String = "Player",
localColor: PlayerColor = .blue
) {
guard let id = entity.id else { return nil }
@@ -123,11 +125,14 @@ 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(
+ let participantLists = Self.computeParticipants(
entity: entity,
localAuthorID: localAuthorID,
+ localName: localName,
localColor: localColor
)
+ self.participants = participantLists.remote
+ self.allParticipants = participantLists.all
self.hasUnreadOtherMoves = Self.computeHasUnread(
isShared: self.isShared,
completedAt: entity.completedAt,
@@ -154,9 +159,12 @@ struct GameSummary: Identifiable, Equatable {
private static func computeParticipants(
entity: GameEntity,
localAuthorID: String?,
+ localName: String,
localColor: PlayerColor
- ) -> [GameParticipantSummary] {
- guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else { return [] }
+ ) -> (remote: [GameParticipantSummary], all: [GameParticipantSummary]) {
+ guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else {
+ return ([], [])
+ }
var namesByAuthor: [String: String] = [:]
let playerEntities = (entity.players as? Set<PlayerEntity>) ?? []
@@ -175,12 +183,23 @@ struct GameSummary: Identifiable, Equatable {
moveAuthorIDs.append(authorID)
}
- return ParticipantSummaries.remoteParticipants(
- namesByAuthor: namesByAuthor,
- moveAuthorIDs: moveAuthorIDs,
- nicknamesByAuthor: friendNicknames(in: entity.managedObjectContext),
- localAuthorID: localAuthorID,
- localColor: localColor
+ let nicknames = friendNicknames(in: entity.managedObjectContext)
+ return (
+ ParticipantSummaries.remoteParticipants(
+ namesByAuthor: namesByAuthor,
+ moveAuthorIDs: moveAuthorIDs,
+ nicknamesByAuthor: nicknames,
+ localAuthorID: localAuthorID,
+ localColor: localColor
+ ),
+ ParticipantSummaries.allParticipants(
+ namesByAuthor: namesByAuthor,
+ moveAuthorIDs: moveAuthorIDs,
+ nicknamesByAuthor: nicknames,
+ localAuthorID: localAuthorID,
+ localName: localName,
+ localColor: localColor
+ )
)
}
@@ -242,6 +261,7 @@ final class GameSummaryCache {
let gridHeight: Int16
let blockMask: Data?
let localAuthorID: String?
+ let localName: String
let localColorID: String
let playersSignature: [String]
let movesAuthorIDs: [String]
@@ -252,6 +272,7 @@ final class GameSummaryCache {
func summary(
for entity: GameEntity,
localAuthorID: String? = nil,
+ localName: String = "Player",
localColor: PlayerColor = .blue
) -> GameSummary? {
let key = Key(
@@ -269,6 +290,7 @@ final class GameSummaryCache {
gridHeight: entity.gridHeight,
blockMask: entity.blockMask,
localAuthorID: localAuthorID,
+ localName: localName,
localColorID: localColor.id,
playersSignature: Self.playersSignature(for: entity),
movesAuthorIDs: Self.movesAuthorIDs(for: entity),
@@ -280,6 +302,7 @@ final class GameSummaryCache {
guard let fresh = GameSummary(
entity: entity,
localAuthorID: localAuthorID,
+ localName: localName,
localColor: localColor
) else { return nil }
entries[entity.objectID] = (key, fresh)
diff --git a/Crossmate/Views/GameList/GameCardView.swift b/Crossmate/Views/GameList/GameCardView.swift
@@ -113,7 +113,10 @@ struct GameListThumbnailView: View {
}
if showsParticipantStrip {
- SharedParticipantsButton(participants: game.participants)
+ SharedParticipantsButton(
+ stripParticipants: game.participants,
+ menuParticipants: game.allParticipants
+ )
.frame(width: 60)
}
}
@@ -175,31 +178,32 @@ struct GameOverflowMenu: View {
}
struct SharedParticipantsButton: View {
- let participants: [GameParticipantSummary]
+ let stripParticipants: [GameParticipantSummary]
+ let menuParticipants: [GameParticipantSummary]
@State private var isShowingParticipants = false
var body: some View {
Button {
isShowingParticipants = true
} label: {
- ParticipantColorStrip(participants: participants)
+ ParticipantColorStrip(participants: stripParticipants)
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.popover(isPresented: $isShowingParticipants) {
- GameParticipantsPopover(participants: participants)
+ GameParticipantsPopover(participants: menuParticipants)
.presentationCompactAdaptation(.popover)
}
}
private var accessibilityLabel: String {
- if participants.isEmpty {
+ if stripParticipants.isEmpty {
return "Shared puzzle"
}
- if participants.count == 1, let participant = participants.first {
+ if stripParticipants.count == 1, let participant = stripParticipants.first {
return "Shared with \(participant.name)"
}
- return "Shared with \(participants.count) players"
+ return "Shared with \(stripParticipants.count) players"
}
}
@@ -251,7 +255,7 @@ private struct GameParticipantsPopover: View {
Circle()
.fill(participant.color.selectionFill)
.frame(width: 14, height: 14)
- Text(participant.name)
+ Text(participant.isLocal ? "\(participant.name) (you)" : participant.name)
.font(.subheadline)
.lineLimit(1)
.truncationMode(.tail)
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -228,6 +228,7 @@ struct GameListView: View {
summaryCache.summary(
for: $0,
localAuthorID: authorIdentity.currentID,
+ localName: preferences.name,
localColor: preferences.color
)
}
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -152,11 +152,16 @@ struct GameSummaryThumbnailTests {
let summary = try #require(GameSummary(
entity: entity,
localAuthorID: "_Local",
+ localName: "Local Player",
localColor: .blue
))
#expect(summary.participants.map(\.authorID) == ["_Alice", "_Bob"])
#expect(summary.participants.map(\.name) == ["Ace", "Waiting for player..."])
+ #expect(summary.participants.allSatisfy { !$0.isLocal })
+ #expect(summary.allParticipants.map(\.authorID) == ["_Local", "_Alice", "_Bob"])
+ #expect(summary.allParticipants.map(\.name) == ["Local Player", "Ace", "Waiting for player..."])
+ #expect(summary.allParticipants.map(\.isLocal) == [true, false, false])
#expect(!summary.participants.map(\.color.id).contains(PlayerColor.blue.id))
#expect(Set(summary.participants.map(\.color.id)).count == 2)
}