commit 0a6cf0cddbb01cbee0719888333f1ec7f066e560
parent 47eb3e6ef088c00613b12d278f29252566b79ba9
Author: Michael Camilleri <[email protected]>
Date: Fri, 17 Jul 2026 09:45:50 +0900
Add a player colour legend to the share card
A viewer of the shared completion card had no way to tell which of the
washed colours in the silhouette belonged to whom. This commit adds a
legend centred under the silhouette: a coloured dot, an anonymised name
— 'Me' first, then 'Friend 1', 'Friend 2', … in contribution order — and
each player's count of correctly filled squares, wrapping onto further
centred lines when a game's players outgrow one row. The dots wear the
selection-strength wash rather than the squares' faint tint so they read
clearly at dot size, and the counts are computed from the live grid —
the same source as the card's tints — so the legend always matches the
coloured squares.
The card's completion line now reads 'Friend(s)' rather than
'Crossmate(s)', and the puzzle title drops its quotation marks now that
it sits as the card's headline.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
1 file changed, 163 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Views/Puzzle/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift
@@ -18,7 +18,19 @@ struct SuccessPanel: View {
private typealias Contribution = PuzzleGridStats.ParticipantCount
private var gridStats: PuzzleGridStats {
- let replayCells = replay?.frame?.cells
+ makeGridStats(replayCells: replay?.frame?.cells)
+ }
+
+ /// Stats over the live grid, ignoring any loaded replay frame — the same
+ /// source the share card's tints read, so the card's legend counts match
+ /// its tinted squares.
+ private var liveGridStats: PuzzleGridStats {
+ makeGridStats(replayCells: nil)
+ }
+
+ private func makeGridStats(
+ replayCells: [GridPosition: JournalCellState]?
+ ) -> PuzzleGridStats {
let hasRemotePlayers = roster.entries.contains { !$0.isLocal }
return PuzzleGridStats(
puzzle: session.puzzle,
@@ -78,24 +90,26 @@ struct SuccessPanel: View {
private func contributorTint(for authorID: String?) -> Color? {
guard let authorID else { return nil }
let hasRemotePlayers = roster.entries.contains { !$0.isLocal }
- guard hasRemotePlayers else { return shareCardAuthorWash(for: preferences.color) }
+ guard hasRemotePlayers else {
+ return shareCardWash(for: preferences.color, opacity: PlayerColor.authorTintOpacity)
+ }
guard let color = roster.entries.first(where: { $0.authorID == authorID })?.color else {
return nil
}
- return shareCardAuthorWash(for: color)
+ return shareCardWash(for: color, opacity: PlayerColor.authorTintOpacity)
}
- private func shareCardAuthorWash(for color: PlayerColor) -> Color {
+ private func shareCardWash(for color: PlayerColor, opacity: Double) -> Color {
let resolved = UIColor(color.tint).resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
- return color.authorTintFill
+ return color.tint.opacity(opacity)
}
- let washAlpha = CGFloat(PlayerColor.authorTintOpacity) * alpha
+ let washAlpha = CGFloat(opacity) * alpha
return Color(
red: Double(1 - washAlpha + red * washAlpha),
green: Double(1 - washAlpha + green * washAlpha),
@@ -123,11 +137,11 @@ struct SuccessPanel: View {
}
/// The card's bottom line: "Completed Solo" or "Completed with N
- /// Crossmate(s)", with "in <time>" appended when a clock exists and the
+ /// Friend(s)", with "in <time>" appended when a clock exists and the
/// user hasn't opted it out.
private func completionLine(includeClock: Bool) -> String {
var text = friendCount > 0
- ? "Completed with \(friendCount) Crossmate\(friendCount == 1 ? "" : "s")"
+ ? "Completed with \(friendCount) Friend\(friendCount == 1 ? "" : "s")"
: "Completed Solo"
if includeClock, hasClock {
text += " in \(TimeLog.clockString(roster.solveTime()))"
@@ -135,10 +149,53 @@ struct SuccessPanel: View {
return text
}
+ /// Legend rows for the share card: the local player first as 'Me', then
+ /// each co-player as 'Friend N' in contribution order. Counts come from
+ /// the live grid; the dots use the selection-strength wash — stronger
+ /// than the squares' own tint — so they read clearly at dot size.
+ private func shareLegend() -> [SuccessShareCard.LegendEntry] {
+ let counts = liveGridStats.participantCounts(
+ entries: roster.entries,
+ localName: preferences.name,
+ localColor: preferences.color,
+ includesUnattributedLocalWhenNoRemote: true
+ )
+ // Unattributed counts only surface when there are no remote players,
+ // so a nil authorID is always the local solver.
+ let isLocal: (Contribution) -> Bool = {
+ $0.authorID == nil || $0.authorID == roster.localAuthorID
+ }
+ let dotColor: (PlayerColor?) -> Color = { color in
+ guard let color else { return Color(white: 0.75) }
+ return shareCardWash(for: color, opacity: color.selectedOpacity)
+ }
+
+ var legend = [SuccessShareCard.LegendEntry(
+ id: "me",
+ name: "Me",
+ dot: dotColor(preferences.color),
+ count: counts.filter(isLocal).reduce(0) { $0 + $1.count }
+ )]
+ let friends = counts.filter { !isLocal($0) }.sorted {
+ if $0.count != $1.count { return $0.count > $1.count }
+ return $0.name < $1.name
+ }
+ for (index, friend) in friends.enumerated() {
+ legend.append(SuccessShareCard.LegendEntry(
+ id: friend.id,
+ name: "Friend \(index + 1)",
+ dot: dotColor(friend.color),
+ count: friend.count
+ ))
+ }
+ return legend
+ }
+
@MainActor private func makeShareImage(includeClock: Bool) -> Image? {
let card = SuccessShareCard(
puzzle: session.puzzle,
cellTints: shareCellTints,
+ legend: shareLegend(),
completionText: completionLine(includeClock: includeClock)
)
let renderer = ImageRenderer(content: card)
@@ -568,10 +625,22 @@ private struct SharePreviewSheet: View {
/// the share sheet. Uses explicit colours (not the environment) so the exported
/// image reads the same regardless of the device's light/dark setting.
private struct SuccessShareCard: View {
+ /// One entry of the colour legend under the silhouette.
+ struct LegendEntry: Identifiable {
+ let id: String
+ /// 'Me' for the local player, 'Friend N' for co-players.
+ let name: String
+ /// The dot's fill — the player's selection-strength wash.
+ let dot: Color
+ let count: Int
+ }
+
let puzzle: Puzzle
/// Per-cell author tints, parallel to `puzzle.cells`; `nil` draws neutral.
let cellTints: [[Color?]]
- /// "Completed Solo/with N Crossmate(s)", plus the solve time unless the
+ /// The colour legend, local player first.
+ let legend: [LegendEntry]
+ /// "Completed Solo/with N Friend(s)", plus the solve time unless the
/// user opted it out.
let completionText: String
@@ -593,7 +662,7 @@ private struct SuccessShareCard: View {
.foregroundStyle(.secondary)
VStack(spacing: 6) {
- Text("‘\(puzzle.title)’")
+ Text(puzzle.title)
.font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundStyle(.black)
.multilineTextAlignment(.center)
@@ -616,6 +685,28 @@ private struct SuccessShareCard: View {
)
.frame(maxWidth: 320)
+ // The colour legend: which colour is the sharer's, and which are
+ // their friends', with each player's filled-square count. Entries
+ // run left-to-right from 'Me' and wrap onto further centred lines
+ // when they outgrow the card.
+ CenteredFlowLayout(horizontalSpacing: 18, verticalSpacing: 6) {
+ ForEach(legend) { entry in
+ HStack(spacing: 7) {
+ Circle()
+ .fill(entry.dot)
+ .frame(width: 9, height: 9)
+ // A soft black: dimmer than the headline text without
+ // falling to the secondary grey the count uses.
+ Text(entry.name)
+ .foregroundStyle(Color(white: 0.3))
+ Text("\(entry.count)")
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .font(.system(size: 14, weight: .regular, design: .rounded))
+
Text(completionText)
.font(.system(size: 20, weight: .medium, design: .rounded))
.monospacedDigit()
@@ -628,6 +719,68 @@ private struct SuccessShareCard: View {
}
}
+/// A wrapping row layout for the share card's legend: subviews flow
+/// left-to-right at their ideal sizes, wrapping onto a new line when the
+/// next one would overflow, with every line centred horizontally.
+private struct CenteredFlowLayout: Layout {
+ var horizontalSpacing: CGFloat
+ var verticalSpacing: CGFloat
+
+ /// Subview indices grouped into lines that fit within `width`.
+ private func lines(subviews: Subviews, in width: CGFloat) -> [[Int]] {
+ var lines: [[Int]] = []
+ var current: [Int] = []
+ var x: CGFloat = 0
+ for index in subviews.indices {
+ let size = subviews[index].sizeThatFits(.unspecified)
+ if !current.isEmpty, x + horizontalSpacing + size.width > width {
+ lines.append(current)
+ current = [index]
+ x = size.width
+ } else {
+ x += (current.isEmpty ? 0 : horizontalSpacing) + size.width
+ current.append(index)
+ }
+ }
+ if !current.isEmpty { lines.append(current) }
+ return lines
+ }
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let width = proposal.width ?? .infinity
+ var maxLineWidth: CGFloat = 0
+ var height: CGFloat = 0
+ for (index, line) in lines(subviews: subviews, in: width).enumerated() {
+ let sizes = line.map { subviews[$0].sizeThatFits(.unspecified) }
+ let lineWidth = sizes.reduce(0) { $0 + $1.width }
+ + horizontalSpacing * CGFloat(line.count - 1)
+ maxLineWidth = max(maxLineWidth, lineWidth)
+ height += (index == 0 ? 0 : verticalSpacing) + (sizes.map(\.height).max() ?? 0)
+ }
+ return CGSize(width: proposal.width ?? maxLineWidth, height: height)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ var y = bounds.minY
+ for line in lines(subviews: subviews, in: bounds.width) {
+ let sizes = line.map { subviews[$0].sizeThatFits(.unspecified) }
+ let lineWidth = sizes.reduce(0) { $0 + $1.width }
+ + horizontalSpacing * CGFloat(line.count - 1)
+ let lineHeight = sizes.map(\.height).max() ?? 0
+ var x = bounds.minX + (bounds.width - lineWidth) / 2
+ for (offset, index) in line.enumerated() {
+ subviews[index].place(
+ at: CGPoint(x: x, y: y + (lineHeight - sizes[offset].height) / 2),
+ anchor: .topLeading,
+ proposal: .unspecified
+ )
+ x += sizes[offset].width + horizontalSpacing
+ }
+ y += lineHeight + verticalSpacing
+ }
+ }
+}
+
/// A two-tone rendering of the grid shape — open squares light, blocks dark —
/// drawn with `Canvas` so it scales crisply at any render size.
private struct ShareGridSilhouette: View {