crossmate

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

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 }