crossmate

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

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:
MCrossmate/Models/ParticipantSummaries.swift | 36+++++++++++++++++++++++++++++++++++-
MCrossmate/Persistence/GameStore.swift | 41++++++++++++++++++++++++++++++++---------
MCrossmate/Views/GameList/GameCardView.swift | 20++++++++++++--------
MCrossmate/Views/GameList/GameListView.swift | 1+
MTests/Unit/GameSummaryThumbnailTests.swift | 5+++++
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) }