SuccessPanel.swift (7099B)
1 import SwiftUI 2 3 struct SuccessPanel: View { 4 let session: PlayerSession 5 let roster: PlayerRoster 6 @Environment(PlayerPreferences.self) private var preferences 7 8 private struct Contribution: Identifiable { 9 let authorID: String? 10 let name: String 11 let color: PlayerColor? 12 let count: Int 13 14 var id: String { authorID ?? "unattributed" } 15 } 16 17 private var revealedSquareCount: Int { 18 var count = 0 19 for r in 0..<session.puzzle.height { 20 for c in 0..<session.puzzle.width { 21 let cell = session.puzzle.cells[r][c] 22 guard !cell.isBlock else { continue } 23 if session.game.squares[r][c].mark.isRevealed { 24 count += 1 25 } 26 } 27 } 28 return count 29 } 30 31 private var revealedSquaresText: String { 32 switch revealedSquareCount { 33 case 0: 34 return "No squares revealed" 35 case 1: 36 return "1 square revealed" 37 default: 38 return "\(revealedSquareCount) squares revealed" 39 } 40 } 41 42 private var contributions: [Contribution] { 43 var counts: [String?: Int] = [:] 44 for r in 0..<session.puzzle.height { 45 for c in 0..<session.puzzle.width { 46 let cell = session.puzzle.cells[r][c] 47 guard !cell.isBlock else { continue } 48 let square = session.game.squares[r][c] 49 guard !square.mark.isRevealed else { continue } 50 let entry = square.entry 51 guard !entry.isEmpty else { continue } 52 if cell.solution != nil, !cell.accepts(entry) { continue } 53 counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 54 } 55 } 56 57 let entries = roster.entries 58 let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) 59 let hasRemotePlayers = entries.contains { !$0.isLocal } 60 let usesLocalFallback = entries.isEmpty 61 let rosterContributions: [Contribution] 62 if usesLocalFallback { 63 rosterContributions = [ 64 Contribution( 65 authorID: nil, 66 name: preferences.name, 67 color: preferences.color, 68 count: counts[nil] ?? 0 69 ) 70 ] 71 } else { 72 rosterContributions = entries.map { entry in 73 Contribution( 74 authorID: entry.authorID, 75 name: entry.name, 76 color: entry.color, 77 count: counts[entry.authorID] ?? 0 78 ) 79 } 80 } 81 let rosterAuthorIDs = Set(entries.map(\.authorID)) 82 83 let countedContributions = counts.compactMap { authorID, count -> Contribution? in 84 if let authorID, rosterAuthorIDs.contains(authorID) { 85 return nil 86 } 87 if authorID == nil && usesLocalFallback { 88 return nil 89 } 90 if let authorID, let entry = entryByAuthorID[authorID] { 91 return Contribution(authorID: authorID, name: entry.name, color: entry.color, count: count) 92 } 93 if authorID == nil && !hasRemotePlayers { 94 return Contribution(authorID: nil, name: preferences.name, color: preferences.color, count: count) 95 } 96 return Contribution(authorID: authorID, name: "Player", color: nil, count: count) 97 } 98 99 return (rosterContributions + countedContributions) 100 .sorted { 101 if $0.count != $1.count { return $0.count > $1.count } 102 return $0.name < $1.name 103 } 104 } 105 106 private func normalizedAuthorID(_ authorID: String?) -> String? { 107 guard let authorID else { 108 return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID 109 } 110 return authorID 111 } 112 113 var body: some View { 114 HStack(alignment: .center, spacing: 16) { 115 VStack(alignment: .center, spacing: 8) { 116 Image(systemName: "checkmark.seal.fill") 117 .font(.system(size: 44)) 118 .foregroundStyle(.tint) 119 120 VStack(alignment: .center, spacing: 2) { 121 Text(session.puzzle.title) 122 .font(.subheadline.weight(.semibold)) 123 .lineLimit(1) 124 if let date = session.puzzle.date { 125 Text(date, format: .dateTime.day().month(.abbreviated).year()) 126 .font(.caption) 127 .foregroundStyle(.secondary) 128 .lineLimit(1) 129 } 130 if let author = session.puzzle.author { 131 Text(author) 132 .font(.caption) 133 .foregroundStyle(.secondary) 134 .lineLimit(1) 135 } 136 if let publisher = session.puzzle.publisher { 137 Text(publisher) 138 .font(.caption) 139 .foregroundStyle(.secondary) 140 .lineLimit(1) 141 } 142 } 143 .multilineTextAlignment(.center) 144 .frame(maxWidth: .infinity, alignment: .center) 145 } 146 .frame(maxWidth: .infinity, alignment: .center) 147 148 VStack(alignment: .leading, spacing: 12) { 149 Text("Players") 150 .font(.headline) 151 152 ScrollView { 153 VStack(alignment: .leading, spacing: 6) { 154 ForEach(contributions) { contribution in 155 HStack(spacing: 8) { 156 Circle() 157 .fill(contribution.color?.tint ?? Color.secondary) 158 .frame(width: 8, height: 8) 159 Text(contribution.name) 160 .font(.subheadline) 161 .lineLimit(1) 162 Spacer(minLength: 8) 163 Text("\(contribution.count)") 164 .font(.subheadline.monospacedDigit().weight(.semibold)) 165 } 166 } 167 168 Text(revealedSquaresText) 169 .font(.footnote) 170 .foregroundStyle(.secondary) 171 .padding(.top, 16) 172 .frame(maxWidth: .infinity, alignment: .center) 173 } 174 } 175 .scrollIndicators(.hidden) 176 } 177 .frame(maxWidth: .infinity, alignment: .leading) 178 .padding(.top, 8) 179 } 180 .padding(.leading, 18) 181 .padding(.trailing, 24) 182 .padding(.vertical, 10) 183 .frame(maxWidth: .infinity, maxHeight: .infinity) 184 } 185 }