commit f194d2243745cda62eacf99aa25416f7a467d02a
parent fd9f55974ee025eae21cb41bc143afd3f29a54b3
Author: Michael Camilleri <[email protected]>
Date: Fri, 1 May 2026 22:14:59 +0900
Add scoreboard to success panel
This commit adds a scoreboard to the success panel displayed when a
crossword is successfully solved. It uses the number of correct squares
attributed to each player as the basis for points.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 187 insertions(+), 25 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -24,6 +24,7 @@
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; };
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; };
+ 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
@@ -31,6 +32,7 @@
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
+ 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
@@ -43,7 +45,6 @@
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 */; };
@@ -97,7 +98,6 @@
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>"; };
@@ -150,6 +150,7 @@
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; };
+ B23A692318044351247606DF /* SuccessPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessPanel.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; };
@@ -159,6 +160,7 @@
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
+ D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
@@ -214,7 +216,7 @@
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */,
1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */,
- 0F84030FF54D1F492EB091BB /* GameStoreUnseenMovesTests.swift */,
+ D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */,
BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */,
543481AA9FA32BF14076EB1C /* MoveLogTests.swift */,
9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */,
@@ -304,6 +306,7 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
+ B23A692318044351247606DF /* SuccessPanel.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -465,7 +468,7 @@
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */,
905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */,
- 91E64D507D3ED109F9544133 /* GameStoreUnseenMovesTests.swift in Sources */,
+ 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */,
3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */,
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
@@ -532,6 +535,7 @@
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
+ 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */,
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */,
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -261,7 +261,7 @@ struct PuzzleView: View {
ClueBarSlot(session: session)
ZStack {
if isSolved {
- SuccessPanel(session: session)
+ SuccessPanel(session: session, roster: roster)
.transition(.move(edge: .bottom))
} else {
KeyboardView(session: session)
@@ -458,26 +458,6 @@ private struct ClueBar: View {
}
}
-private struct SuccessPanel: View {
- let session: PlayerSession
-
- var body: some View {
- VStack(spacing: 8) {
- Image(systemName: "checkmark.seal.fill")
- .font(.system(size: 44))
- .foregroundStyle(.tint)
- Text("Solved!")
- .font(.title2.weight(.bold))
- if let author = session.puzzle.author {
- Text("By \(author)")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
-}
-
private struct RebusModal: View {
let text: String
diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift
@@ -0,0 +1,178 @@
+import SwiftUI
+
+struct SuccessPanel: View {
+ let session: PlayerSession
+ let roster: PlayerRoster?
+ @Environment(PlayerPreferences.self) private var preferences
+
+ private struct Contribution: Identifiable {
+ let authorID: String?
+ let name: String
+ let color: PlayerColor?
+ let count: Int
+
+ var id: String { authorID ?? "unattributed" }
+ }
+
+ private var revealedSquareCount: Int {
+ var count = 0
+ for r in 0..<session.puzzle.height {
+ for c in 0..<session.puzzle.width {
+ let cell = session.puzzle.cells[r][c]
+ guard !cell.isBlock else { continue }
+ if session.game.squares[r][c].mark.isRevealed {
+ count += 1
+ }
+ }
+ }
+ return count
+ }
+
+ private var revealedSquaresText: String {
+ switch revealedSquareCount {
+ case 0:
+ return "No squares revealed"
+ case 1:
+ return "1 square revealed"
+ default:
+ return "\(revealedSquareCount) squares revealed"
+ }
+ }
+
+ private var contributions: [Contribution] {
+ var counts: [String?: Int] = [:]
+ for r in 0..<session.puzzle.height {
+ for c in 0..<session.puzzle.width {
+ let cell = session.puzzle.cells[r][c]
+ guard !cell.isBlock else { continue }
+ let square = session.game.squares[r][c]
+ guard !square.mark.isRevealed else { continue }
+ let entry = square.entry
+ guard !entry.isEmpty else { continue }
+ if let solution = cell.solution, entry != solution.uppercased() { continue }
+ counts[square.letterAuthorID, default: 0] += 1
+ }
+ }
+
+ let entries = roster?.entries ?? []
+ let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) })
+ let hasRemotePlayers = entries.contains { !$0.isLocal }
+ let usesLocalFallback = entries.isEmpty
+ let rosterContributions: [Contribution]
+ if usesLocalFallback {
+ rosterContributions = [
+ Contribution(
+ authorID: nil,
+ name: preferences.name,
+ color: preferences.color,
+ count: counts[nil] ?? 0
+ )
+ ]
+ } else {
+ rosterContributions = entries.map { entry in
+ Contribution(
+ authorID: entry.authorID,
+ name: entry.name,
+ color: entry.color,
+ count: counts[entry.authorID] ?? 0
+ )
+ }
+ }
+ let rosterAuthorIDs = Set(entries.map(\.authorID))
+
+ let countedContributions = counts.compactMap { authorID, count -> Contribution? in
+ if let authorID, rosterAuthorIDs.contains(authorID) {
+ return nil
+ }
+ if authorID == nil && usesLocalFallback {
+ return nil
+ }
+ if let authorID, let entry = entryByAuthorID[authorID] {
+ return Contribution(authorID: authorID, name: entry.name, color: entry.color, count: count)
+ }
+ if authorID == nil && !hasRemotePlayers {
+ return Contribution(authorID: nil, name: preferences.name, color: preferences.color, count: count)
+ }
+ return Contribution(authorID: authorID, name: "Player", color: nil, count: count)
+ }
+
+ return (rosterContributions + countedContributions)
+ .sorted {
+ if $0.count != $1.count { return $0.count > $1.count }
+ return $0.name < $1.name
+ }
+ }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 16) {
+ VStack(alignment: .center, spacing: 8) {
+ Image(systemName: "checkmark.seal.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(.tint)
+
+ VStack(alignment: .center, spacing: 2) {
+ Text(session.puzzle.title)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(1)
+ if let date = session.puzzle.date {
+ Text(date, format: .dateTime.day().month(.abbreviated).year())
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ if let author = session.puzzle.author {
+ Text(author)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ if let publisher = session.puzzle.publisher {
+ Text(publisher)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Players")
+ .font(.headline)
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(contributions) { contribution in
+ HStack(spacing: 8) {
+ Circle()
+ .fill(contribution.color?.tint ?? Color.secondary)
+ .frame(width: 8, height: 8)
+ Text(contribution.name)
+ .font(.subheadline)
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ Text("\(contribution.count)")
+ .font(.subheadline.monospacedDigit().weight(.semibold))
+ }
+ }
+
+ Text(revealedSquaresText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding(.top, 16)
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+ .scrollIndicators(.hidden)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 8)
+ }
+ .padding(.leading, 18)
+ .padding(.trailing, 24)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}