crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++----
MCrossmate/Views/PuzzleView.swift | 22+---------------------
ACrossmate/Views/SuccessPanel.swift | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}