crossmate

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

SuccessPanel.swift (34853B)


      1 import SwiftUI
      2 
      3 struct SuccessPanel: View {
      4     let session: PlayerSession
      5     let roster: PlayerRoster
      6     /// Drives the finish-banner replay scrubber and the grid override above.
      7     var replay: ReplayControls? = nil
      8     /// Loads the merged journal (Phase 2b). Called once when the banner appears
      9     /// (and again on "check again" while waiting on a peer's upload).
     10     var loadReplay: (() async -> JournalReplayResult)? = nil
     11     @Environment(PlayerPreferences.self) private var preferences
     12     @Environment(\.displayScale) private var displayScale
     13     /// The rendered completion card, set when the user taps Share. Carrying the
     14     /// image on the sheet's `item` (rather than a separate flag) guarantees it's
     15     /// present the first time the sheet opens.
     16     @State private var sharePreview: SharePreviewItem?
     17 
     18     private typealias Contribution = PuzzleGridStats.ParticipantCount
     19 
     20     private var gridStats: PuzzleGridStats {
     21         makeGridStats(replayCells: replay?.frame?.cells)
     22     }
     23 
     24     /// Stats over the live grid, ignoring any loaded replay frame — the same
     25     /// source the share card's tints read, so the card's legend counts match
     26     /// its tinted squares.
     27     private var liveGridStats: PuzzleGridStats {
     28         makeGridStats(replayCells: nil)
     29     }
     30 
     31     private func makeGridStats(
     32         replayCells: [GridPosition: JournalCellState]?
     33     ) -> PuzzleGridStats {
     34         let hasRemotePlayers = roster.entries.contains { !$0.isLocal }
     35         return PuzzleGridStats(
     36             puzzle: session.puzzle,
     37             hasRemotePlayers: hasRemotePlayers,
     38             localAuthorID: roster.localAuthorID,
     39             countsIncorrectEntries: false
     40         ) { r, c in
     41             PuzzleGridStats.CellState(
     42                 displayedCellState(row: r, col: c, replayCells: replayCells)
     43             )
     44         }
     45     }
     46 
     47     private func revealedSquaresText(for stats: PuzzleGridStats) -> String {
     48         switch stats.revealedSquareCount {
     49         case 0:
     50             return "No squares revealed"
     51         case 1:
     52             return "1 square revealed"
     53         default:
     54             return "\(stats.revealedSquareCount) squares revealed"
     55         }
     56     }
     57 
     58     /// Total active solve time, frozen at completion (`roster.solveTime` bounds
     59     /// the union at `completedAt`). "Finished" rather than "Solved" so it reads
     60     /// correctly for a resignation too — and because an offline solo win carries
     61     /// no `completedBy`, so a win and a resign aren't reliably distinguishable
     62     /// here anyway. `nil` when there is no time to show — an archive made before
     63     /// the clock existed — so the line is omitted rather than reading 0:00.
     64     private var finishedInText: String? {
     65         let seconds = roster.solveTime()
     66         guard seconds > 0 else { return nil }
     67         return "Finished in \(TimeLog.clockString(seconds))"
     68     }
     69 
     70     /// Co-players in the roster other than the local solver. Drives the
     71     /// "with N friends" clause in the share text; 0 for a solo solve.
     72     private var friendCount: Int {
     73         roster.entries.filter { !$0.isLocal }.count
     74     }
     75 
     76     /// Whether a solve time is available to share at all (an archive made
     77     /// before the clock existed has none).
     78     private var hasClock: Bool { roster.solveTime() > 0 }
     79 
     80     /// The tint for a filled square, using the lighter author-attribution wash
     81     /// pre-composited over white. The share card's Canvas paints gridlines
     82     /// first, so using a translucent fill would blend the wash with gray rather
     83     /// than the white cell colour the Clue Bar uses.
     84     /// Keyed on authorship, not the reveal mark: a revealed square clears its
     85     /// author (Game.revealCells), and that nil author survives persistence even
     86     /// when the `.revealed` mark doesn't — so an unauthored square is reliably
     87     /// left untinted on both live and reloaded games. In a non-shared puzzle an
     88     /// authored square is the user's own colour; in a shared puzzle it's the
     89     /// contributing author's colour.
     90     private func contributorTint(for authorID: String?) -> Color? {
     91         guard let authorID else { return nil }
     92         let hasRemotePlayers = roster.entries.contains { !$0.isLocal }
     93         guard hasRemotePlayers else {
     94             return shareCardWash(for: preferences.color, opacity: PlayerColor.authorTintOpacity)
     95         }
     96         guard let color = roster.entries.first(where: { $0.authorID == authorID })?.color else {
     97             return nil
     98         }
     99         return shareCardWash(for: color, opacity: PlayerColor.authorTintOpacity)
    100     }
    101 
    102     private func shareCardWash(for color: PlayerColor, opacity: Double) -> Color {
    103         let resolved = UIColor(color.tint).resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
    104         var red: CGFloat = 0
    105         var green: CGFloat = 0
    106         var blue: CGFloat = 0
    107         var alpha: CGFloat = 0
    108         guard resolved.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
    109             return color.tint.opacity(opacity)
    110         }
    111 
    112         let washAlpha = CGFloat(opacity) * alpha
    113         return Color(
    114             red: Double(1 - washAlpha + red * washAlpha),
    115             green: Double(1 - washAlpha + green * washAlpha),
    116             blue: Double(1 - washAlpha + blue * washAlpha)
    117         )
    118     }
    119 
    120     /// Per-cell tints for the share card's silhouette: `nil` for blocks, for
    121     /// revealed squares, and for squares with no attributable author; otherwise
    122     /// the contributor's colour. Reads the live `session.game` — the locally
    123     /// authoritative grid that carries entries, marks (including `.revealed`),
    124     /// and authors promptly, without depending on the journal/replay, which for
    125     /// a shared game only finishes syncing long after completion. The card
    126     /// renders on tap, by which point any resignation reveal has refreshed the
    127     /// live game (the grid already shows the filled answers).
    128     private var shareCellTints: [[Color?]] {
    129         (0..<session.puzzle.height).map { r in
    130             (0..<session.puzzle.width).map { c -> Color? in
    131                 guard !session.puzzle.cells[r][c].isBlock else { return nil }
    132                 let square = session.game.squares[r][c]
    133                 guard !square.mark.isRevealed else { return nil }
    134                 return contributorTint(for: square.letterAuthorID)
    135             }
    136         }
    137     }
    138 
    139     /// The card's bottom line: "Completed Solo" or "Completed with N
    140     /// Friend(s)", with "in <time>" appended when a clock exists and the
    141     /// user hasn't opted it out.
    142     private func completionLine(includeClock: Bool) -> String {
    143         var text = friendCount > 0
    144             ? "Completed with \(friendCount) Friend\(friendCount == 1 ? "" : "s")"
    145             : "Completed Solo"
    146         if includeClock, hasClock {
    147             text += " in \(TimeLog.clockString(roster.solveTime()))"
    148         }
    149         return text
    150     }
    151 
    152     /// Legend rows for the share card: the local player first as 'Me', then
    153     /// each co-player as 'Friend N' in contribution order. Counts come from
    154     /// the live grid; the dots use the selection-strength wash — stronger
    155     /// than the squares' own tint — so they read clearly at dot size.
    156     private func shareLegend() -> [SuccessShareCard.LegendEntry] {
    157         let counts = liveGridStats.participantCounts(
    158             entries: roster.entries,
    159             localName: preferences.name,
    160             localColor: preferences.color,
    161             includesUnattributedLocalWhenNoRemote: true
    162         )
    163         // Unattributed counts only surface when there are no remote players,
    164         // so a nil authorID is always the local solver.
    165         let isLocal: (Contribution) -> Bool = {
    166             $0.authorID == nil || $0.authorID == roster.localAuthorID
    167         }
    168         let dotColor: (PlayerColor?) -> Color = { color in
    169             guard let color else { return Color(white: 0.75) }
    170             return shareCardWash(for: color, opacity: color.selectedOpacity)
    171         }
    172 
    173         var legend = [SuccessShareCard.LegendEntry(
    174             id: "me",
    175             name: "Me",
    176             dot: dotColor(preferences.color),
    177             count: counts.filter(isLocal).reduce(0) { $0 + $1.count }
    178         )]
    179         let friends = counts.filter { !isLocal($0) }.sorted {
    180             if $0.count != $1.count { return $0.count > $1.count }
    181             return $0.name < $1.name
    182         }
    183         for (index, friend) in friends.enumerated() {
    184             legend.append(SuccessShareCard.LegendEntry(
    185                 id: friend.id,
    186                 name: "Friend \(index + 1)",
    187                 dot: dotColor(friend.color),
    188                 count: friend.count
    189             ))
    190         }
    191         return legend
    192     }
    193 
    194     @MainActor private func makeShareImage(includeClock: Bool) -> Image? {
    195         let card = SuccessShareCard(
    196             puzzle: session.puzzle,
    197             cellTints: shareCellTints,
    198             legend: shareLegend(),
    199             completionText: completionLine(includeClock: includeClock)
    200         )
    201         let renderer = ImageRenderer(content: card)
    202         renderer.scale = displayScale
    203         guard let uiImage = renderer.uiImage else { return nil }
    204         return Image(uiImage: uiImage)
    205     }
    206 
    207     private func contributions(for stats: PuzzleGridStats) -> [Contribution] {
    208         stats.participantCounts(
    209             entries: roster.entries,
    210             localName: preferences.name,
    211             localColor: preferences.color,
    212             includesUnattributedLocalWhenNoRemote: true
    213         )
    214         .sorted {
    215             if $0.count != $1.count { return $0.count > $1.count }
    216             return $0.name < $1.name
    217         }
    218     }
    219 
    220     private func displayedCellState(
    221         row: Int,
    222         col: Int,
    223         replayCells: [GridPosition: JournalCellState]?
    224     ) -> JournalCellState {
    225         let position = GridPosition(row: row, col: col)
    226         if let replayCells {
    227             return replayCells[position] ?? .empty
    228         }
    229 
    230         let square = session.game.squares[row][col]
    231         return JournalCellState(
    232             letter: square.entry,
    233             mark: square.mark,
    234             cellAuthorID: square.letterAuthorID
    235         )
    236     }
    237 
    238     var body: some View {
    239         VStack(spacing: 0) {
    240             if let replay {
    241                 ReplayScrubber(replay: replay, fillColor: preferences.color.tint)
    242             }
    243             scoreboard
    244         }
    245         .frame(maxWidth: .infinity, maxHeight: .infinity)
    246         // Drive the load from the panel itself, not the scrubber subview: the
    247         // scrubber renders `EmptyView` until a timeline loads, and SwiftUI skips
    248         // a non-rendering view's `.task`. Keyed on `reloadToken` so "check
    249         // again" re-fires it.
    250         .task(id: replay?.reloadToken) {
    251             guard let replay, let loadReplay else { return }
    252             await replay.load(loadReplay)
    253         }
    254         // A contributor's journal just synced — re-check completeness so a
    255         // waiting scrubber flips to ready without polling. Same async-sequence
    256         // idiom PlayerRoster uses for `.playerRosterShouldRefresh`.
    257         .task {
    258             let gameID = session.mutator.gameID
    259             for await note in NotificationCenter.default.notifications(named: .replayJournalDidSync) {
    260                 guard let replay, case .waiting = replay.status,
    261                       let gameIDs = note.userInfo?["gameIDs"] as? Set<UUID>,
    262                       gameIDs.contains(gameID)
    263                 else { continue }
    264                 replay.retry()
    265             }
    266         }
    267     }
    268 
    269     private var shareIcon: some View {
    270         Image(systemName: "square.and.arrow.up")
    271             .font(.system(size: 18, weight: .medium))
    272             .foregroundStyle(.secondary)
    273             .frame(width: 32, height: 32)
    274             .accessibilityLabel("Share")
    275     }
    276 
    277     private var shareButton: some View {
    278         // Render on tap, not on appear, so the card captures the loaded replay
    279         // frame (the authoritative final grid) rather than the empty pre-load
    280         // state. Present our own preview first — the system share sheet's header
    281         // thumbnail is always small.
    282         Button {
    283             if let image = makeShareImage(includeClock: true) {
    284                 sharePreview = SharePreviewItem(
    285                     title: session.puzzle.title,
    286                     initialImage: image,
    287                     hasClock: hasClock,
    288                     renderImage: { makeShareImage(includeClock: $0) }
    289                 )
    290             }
    291         } label: {
    292             shareIcon
    293         }
    294         .buttonStyle(.plain)
    295         .sheet(item: $sharePreview) { preview in
    296             SharePreviewSheet(item: preview)
    297         }
    298     }
    299 
    300     private var scoreboard: some View {
    301         let stats = gridStats
    302         let contributions = contributions(for: stats)
    303         return HStack(alignment: .center, spacing: 16) {
    304             VStack(alignment: .center, spacing: 8) {
    305                 Image(systemName: "checkmark.seal.fill")
    306                     .font(.system(size: 44))
    307                     .foregroundStyle(.tint)
    308 
    309                 VStack(alignment: .center, spacing: 2) {
    310                     Text(session.puzzle.title)
    311                         .font(.subheadline.weight(.semibold))
    312                         .lineLimit(1)
    313                     if let date = session.puzzle.date {
    314                         Text(date.formatted(date: .long, time: .omitted))
    315                             .font(.caption)
    316                             .foregroundStyle(.secondary)
    317                             .lineLimit(1)
    318                     }
    319                     if let author = session.puzzle.author {
    320                         Text(author)
    321                             .font(.caption)
    322                             .foregroundStyle(.secondary)
    323                             .lineLimit(1)
    324                     }
    325                     if let publisher = session.puzzle.publisher {
    326                         Text(publisher)
    327                             .font(.caption)
    328                             .foregroundStyle(.secondary)
    329                             .lineLimit(1)
    330                     }
    331                 }
    332                 .multilineTextAlignment(.center)
    333                 .frame(maxWidth: .infinity, alignment: .center)
    334             }
    335             .frame(maxWidth: .infinity, alignment: .center)
    336 
    337             VStack(alignment: .leading, spacing: 12) {
    338                 ScrollView {
    339                     VStack(alignment: .leading, spacing: 6) {
    340                         ForEach(contributions) { contribution in
    341                             HStack(spacing: 8) {
    342                                 Circle()
    343                                     .fill(contribution.color?.tint ?? Color.secondary)
    344                                     .frame(width: 8, height: 8)
    345                                 Text(contribution.name)
    346                                     .font(.subheadline)
    347                                     .lineLimit(1)
    348                                 Spacer(minLength: 8)
    349                                 Text("\(contribution.count)")
    350                                     .font(.subheadline.monospacedDigit().weight(.semibold))
    351                             }
    352                         }
    353 
    354                         HStack(alignment: .center, spacing: 18) {
    355                             VStack(alignment: .center, spacing: 4) {
    356                                 if let finishedInText {
    357                                     Text(finishedInText)
    358                                 }
    359                                 Text(revealedSquaresText(for: stats))
    360                             }
    361                             .font(.footnote)
    362                             .foregroundStyle(.secondary)
    363                             .multilineTextAlignment(.center)
    364 
    365                             shareButton
    366                         }
    367                         .padding(.top, 8)
    368                         .frame(maxWidth: .infinity)
    369                     }
    370                 }
    371                 .scrollIndicators(.hidden)
    372             }
    373             // Cap the list width so a wide iPad column doesn't strand each
    374             // name far from its count, then expand-to-fill and centre that
    375             // capped block within the column.
    376             .frame(maxWidth: 320, alignment: .leading)
    377             .frame(maxWidth: .infinity)
    378             .padding(.top, 14)
    379         }
    380         .padding(.leading, 18)
    381         .padding(.trailing, 24)
    382         .padding(.top, 8)
    383         .padding(.bottom, 4)
    384         .frame(maxWidth: .infinity, maxHeight: .infinity)
    385     }
    386 }
    387 
    388 /// The replay scrubber that sits atop the finish banner. The thumb starts at
    389 /// the far right (the finished grid) and dragging left rewinds the puzzle grid
    390 /// above through its move history. Disabled with a sync caption while a
    391 /// contributing device's journal is still missing (strict completeness).
    392 private struct ReplayScrubber: View {
    393     @Bindable var replay: ReplayControls
    394     /// The local player's accent colour, for the filled track.
    395     var fillColor: Color
    396 
    397     /// Shared height for every scrubber state (slider, waiting, loading) so the
    398     /// banner doesn't change height as it loads, and captions sit vertically
    399     /// centred in the same space the slider occupies.
    400     private static let rowHeight: CGFloat = 24
    401 
    402     var body: some View {
    403         Group {
    404             switch replay.status {
    405             case .ready(let timeline) where timeline.count > 0:
    406                 slider(count: timeline.count)
    407             case .waiting:
    408                 waiting
    409             case .loading:
    410                 caption {
    411                     ProgressView().controlSize(.small)
    412                     Text("Loading replay…")
    413                 }
    414             case .idle, .ready, .unavailable:
    415                 // Not started yet (`.idle`), nothing to replay (empty log), or
    416                 // no reachable history. Hold the row's height anyway so the
    417                 // banner doesn't change size as the load resolves — otherwise
    418                 // the scoreboard below hitches up and down as the scrubber
    419                 // appears then collapses.
    420                 Color.clear.frame(height: Self.rowHeight)
    421             }
    422         }
    423         .padding(.horizontal, 18)
    424         .padding(.top, 14)
    425     }
    426 
    427     private func slider(count: Int) -> some View {
    428         HStack(spacing: 10) {
    429             historyOrSpeedControl
    430             CompactSlider(
    431                 value: $replay.position,
    432                 range: 0...count,
    433                 fillColor: UIColor(fillColor),
    434                 // A manual scrub always wins: cancel autoplay the moment the
    435                 // user grabs the thumb, while preserving the selected speed.
    436                 onUserScrub: { replay.pausePlayback() }
    437             )
    438             PlaybackControl(
    439                 isPlaying: replay.isPlaybackActive,
    440                 selectedSpeed: replay.selectedPlaybackSpeed,
    441                 onTap: { replay.togglePlayback() }
    442             )
    443         }
    444         .frame(height: Self.rowHeight)
    445         // Drive autoplay: play/pause and speed changes restart the loop with
    446         // the current interval. A paused replay yields a `nil` interval, so the
    447         // task exits and the grid rests.
    448         .task(id: PlaybackTaskID(
    449             isActive: replay.isPlaybackActive,
    450             speed: replay.selectedPlaybackSpeed
    451         )) {
    452             guard let interval = replay.playbackStepInterval else { return }
    453             while !Task.isCancelled {
    454                 try? await Task.sleep(for: interval)
    455                 if Task.isCancelled { break }
    456                 replay.advancePlayback()
    457             }
    458         }
    459     }
    460 
    461     private struct PlaybackTaskID: Equatable {
    462         let isActive: Bool
    463         let speed: Int
    464     }
    465 
    466     private var historyOrSpeedControl: some View {
    467         ZStack {
    468             Image(systemName: "clock.arrow.circlepath")
    469                 .font(.footnote)
    470                 .foregroundStyle(.secondary)
    471                 .opacity(replay.isPlaybackActive ? 0 : 1)
    472                 .scaleEffect(replay.isPlaybackActive ? 0.75 : 1)
    473                 .blur(radius: replay.isPlaybackActive ? 2 : 0)
    474                 .accessibilityHidden(replay.isPlaybackActive)
    475 
    476             Button {
    477                 replay.cycleSelectedPlaybackSpeed()
    478             } label: {
    479                 Text("\(replay.selectedPlaybackSpeed)x")
    480                     .font(.caption2.monospacedDigit().weight(.semibold))
    481                     .contentTransition(.numericText())
    482                     .frame(width: 28, height: 18)
    483                     .replayGlass()
    484                     .animation(.easeInOut(duration: 0.16), value: replay.selectedPlaybackSpeed)
    485             }
    486             .foregroundStyle(.secondary)
    487             .buttonStyle(.plain)
    488             .opacity(replay.isPlaybackActive ? 1 : 0)
    489             .scaleEffect(replay.isPlaybackActive ? 1 : 1.35)
    490             .blur(radius: replay.isPlaybackActive ? 0 : 2)
    491             .allowsHitTesting(replay.isPlaybackActive)
    492             .accessibilityHidden(!replay.isPlaybackActive)
    493             .accessibilityLabel("Replay speed")
    494             .accessibilityValue("Speed \(replay.selectedPlaybackSpeed) of \(ReplayControls.maxPlaybackSpeed)")
    495         }
    496         .frame(width: 30, height: 22)
    497         // The symbol/speed glyphs read a touch high against the slider track;
    498         // nudge the pair down a point to sit centred on it.
    499         .offset(y: 1)
    500         .animation(.spring(response: 0.34, dampingFraction: 0.62), value: replay.isPlaybackActive)
    501     }
    502 
    503     private var waiting: some View {
    504         caption {
    505             Image(systemName: "arrow.triangle.2.circlepath")
    506                 .font(.footnote)
    507             if case .waiting(let missing) = replay.status {
    508                 Text("Waiting for \(missing) device\(missing == 1 ? "" : "s") to sync history")
    509                     .lineLimit(1)
    510             }
    511             Spacer(minLength: 8)
    512             Button("Check Again") { replay.retry() }
    513                 .font(.caption.weight(.semibold))
    514                 .buttonStyle(.borderless)
    515         }
    516     }
    517 
    518     private func caption<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
    519         HStack(spacing: 8, content: content)
    520             .font(.caption)
    521             .foregroundStyle(.secondary)
    522             .frame(height: Self.rowHeight)
    523             .frame(maxWidth: .infinity, alignment: .leading)
    524     }
    525 }
    526 
    527 /// A shaped play/pause control for replay autoplay.
    528 private struct PlaybackControl: View {
    529     let isPlaying: Bool
    530     let selectedSpeed: Int
    531     let onTap: () -> Void
    532 
    533     var body: some View {
    534         Button(action: onTap) {
    535             Image(systemName: isPlaying ? "pause.fill" : "play.fill")
    536                 .font(.caption2.weight(.heavy))
    537                 .frame(width: 26, height: 18)
    538                 .padding(.horizontal, 7)
    539                 .padding(.vertical, 4)
    540                 .replayGlass()
    541             .animation(.easeInOut(duration: 0.18), value: isPlaying)
    542         }
    543         .buttonStyle(.plain)
    544         .accessibilityLabel(isPlaying ? "Pause replay" : "Play replay")
    545         .accessibilityValue(isPlaying ? "Playing at speed \(selectedSpeed)" : "Paused")
    546     }
    547 }
    548 
    549 /// Drives `.sheet(item:)`. Carries the initial rendered card (so the sheet
    550 /// never opens empty) plus a closure to re-render the card when the
    551 /// "Include Time" toggle changes.
    552 private struct SharePreviewItem: Identifiable {
    553     let id = UUID()
    554     let title: String
    555     let initialImage: Image
    556     /// Whether a solve time exists to offer the toggle for at all.
    557     let hasClock: Bool
    558     let renderImage: @MainActor (Bool) -> Image?
    559 }
    560 
    561 /// A confirmation sheet that shows the rendered card at full size with its
    562 /// caption and a Share button. Stands in for the system share sheet's tiny
    563 /// preview header so the user can see exactly what they're about to share.
    564 /// When a solve time exists, a toggle re-renders the card with or without it.
    565 private struct SharePreviewSheet: View {
    566     let item: SharePreviewItem
    567     @Environment(\.dismiss) private var dismiss
    568     @State private var includeClock: Bool
    569     @State private var image: Image
    570 
    571     init(item: SharePreviewItem) {
    572         self.item = item
    573         _image = State(initialValue: item.initialImage)
    574         // Off when there's no time to share; the toggle still shows, disabled.
    575         _includeClock = State(initialValue: item.hasClock)
    576     }
    577 
    578     var body: some View {
    579         NavigationStack {
    580             VStack(spacing: 20) {
    581                 image
    582                     .resizable()
    583                     .scaledToFit()
    584                     .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
    585                     .overlay(
    586                         RoundedRectangle(cornerRadius: 16, style: .continuous)
    587                             .strokeBorder(.separator, lineWidth: 0.5)
    588                     )
    589                     .padding()
    590                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    591 
    592                 Toggle("Include Time", isOn: $includeClock)
    593                     .disabled(!item.hasClock)
    594                     .padding(.horizontal)
    595 
    596                 ShareLink(
    597                     item: image,
    598                     subject: Text(item.title),
    599                     preview: SharePreview(item.title, image: image)
    600                 ) {
    601                     Label("Share", systemImage: "square.and.arrow.up")
    602                         .frame(maxWidth: .infinity)
    603                 }
    604                 .buttonStyle(.borderedProminent)
    605                 .controlSize(.large)
    606                 .padding([.horizontal, .bottom])
    607             }
    608             .navigationTitle("Share")
    609             .navigationBarTitleDisplayMode(.inline)
    610             .toolbar {
    611                 ToolbarItem(placement: .cancellationAction) {
    612                     Button("Done") { dismiss() }
    613                 }
    614             }
    615             .onChange(of: includeClock) { _, newValue in
    616                 if let updated = item.renderImage(newValue) { image = updated }
    617             }
    618         }
    619         .presentationDetents([.large])
    620     }
    621 }
    622 
    623 /// The shareable completion card, snapshotted by `ImageRenderer` and handed to
    624 /// the share sheet. Uses explicit colours (not the environment) so the exported
    625 /// image reads the same regardless of the device's light/dark setting.
    626 private struct SuccessShareCard: View {
    627     /// One entry of the colour legend under the silhouette.
    628     struct LegendEntry: Identifiable {
    629         let id: String
    630         /// 'Me' for the local player, 'Friend N' for co-players.
    631         let name: String
    632         /// The dot's fill — the player's selection-strength wash.
    633         let dot: Color
    634         let count: Int
    635     }
    636 
    637     let puzzle: Puzzle
    638     /// Per-cell author tints, parallel to `puzzle.cells`; `nil` draws neutral.
    639     let cellTints: [[Color?]]
    640     /// The colour legend, local player first.
    641     let legend: [LegendEntry]
    642     /// "Completed Solo/with N Friend(s)", plus the solve time unless the
    643     /// user opted it out.
    644     let completionText: String
    645 
    646     /// Fixed render width; the grid sizes itself to the puzzle's aspect ratio.
    647     private static let width: CGFloat = 480
    648 
    649     var body: some View {
    650         VStack(spacing: 24) {
    651             HStack(spacing: 6) {
    652                 Text("Solved with")
    653                 Image("AboutIcon")
    654                     .resizable()
    655                     .scaledToFit()
    656                     .frame(width: 22, height: 22)
    657                     .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
    658                 Text("Crossmate")
    659             }
    660             .font(.system(size: 16, weight: .regular, design: .rounded))
    661             .foregroundStyle(.secondary)
    662 
    663             VStack(spacing: 6) {
    664                 Text(puzzle.title)
    665                     .font(.system(size: 24, weight: .semibold, design: .rounded))
    666                     .foregroundStyle(.black)
    667                     .multilineTextAlignment(.center)
    668                     .lineLimit(2)
    669 
    670                 if let author = puzzle.author {
    671                     Text("by \(author)")
    672                         .font(.system(size: 17, weight: .medium, design: .rounded))
    673                         .foregroundStyle(.black)
    674                         .multilineTextAlignment(.center)
    675                         .lineLimit(1)
    676                 }
    677             }
    678 
    679             ShareGridSilhouette(
    680                 cells: puzzle.cells,
    681                 tints: cellTints,
    682                 rows: puzzle.height,
    683                 cols: puzzle.width
    684             )
    685             .frame(maxWidth: 320)
    686 
    687             // The colour legend: which colour is the sharer's, and which are
    688             // their friends', with each player's filled-square count. Entries
    689             // run left-to-right from 'Me' and wrap onto further centred lines
    690             // when they outgrow the card.
    691             CenteredFlowLayout(horizontalSpacing: 18, verticalSpacing: 6) {
    692                 ForEach(legend) { entry in
    693                     HStack(spacing: 7) {
    694                         Circle()
    695                             .fill(entry.dot)
    696                             .frame(width: 9, height: 9)
    697                         // A soft black: dimmer than the headline text without
    698                         // falling to the secondary grey the count uses.
    699                         Text(entry.name)
    700                             .foregroundStyle(Color(white: 0.3))
    701                         Text("\(entry.count)")
    702                             .monospacedDigit()
    703                             .foregroundStyle(.secondary)
    704                     }
    705                 }
    706             }
    707             .font(.system(size: 14, weight: .regular, design: .rounded))
    708 
    709             Text(completionText)
    710                 .font(.system(size: 20, weight: .medium, design: .rounded))
    711                 .monospacedDigit()
    712                 .foregroundStyle(.black)
    713                 .lineLimit(1)
    714         }
    715         .padding(24)
    716         .frame(width: Self.width)
    717         .background(Color.white)
    718     }
    719 }
    720 
    721 /// A wrapping row layout for the share card's legend: subviews flow
    722 /// left-to-right at their ideal sizes, wrapping onto a new line when the
    723 /// next one would overflow, with every line centred horizontally.
    724 private struct CenteredFlowLayout: Layout {
    725     var horizontalSpacing: CGFloat
    726     var verticalSpacing: CGFloat
    727 
    728     /// Subview indices grouped into lines that fit within `width`.
    729     private func lines(subviews: Subviews, in width: CGFloat) -> [[Int]] {
    730         var lines: [[Int]] = []
    731         var current: [Int] = []
    732         var x: CGFloat = 0
    733         for index in subviews.indices {
    734             let size = subviews[index].sizeThatFits(.unspecified)
    735             if !current.isEmpty, x + horizontalSpacing + size.width > width {
    736                 lines.append(current)
    737                 current = [index]
    738                 x = size.width
    739             } else {
    740                 x += (current.isEmpty ? 0 : horizontalSpacing) + size.width
    741                 current.append(index)
    742             }
    743         }
    744         if !current.isEmpty { lines.append(current) }
    745         return lines
    746     }
    747 
    748     func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    749         let width = proposal.width ?? .infinity
    750         var maxLineWidth: CGFloat = 0
    751         var height: CGFloat = 0
    752         for (index, line) in lines(subviews: subviews, in: width).enumerated() {
    753             let sizes = line.map { subviews[$0].sizeThatFits(.unspecified) }
    754             let lineWidth = sizes.reduce(0) { $0 + $1.width }
    755                 + horizontalSpacing * CGFloat(line.count - 1)
    756             maxLineWidth = max(maxLineWidth, lineWidth)
    757             height += (index == 0 ? 0 : verticalSpacing) + (sizes.map(\.height).max() ?? 0)
    758         }
    759         return CGSize(width: proposal.width ?? maxLineWidth, height: height)
    760     }
    761 
    762     func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    763         var y = bounds.minY
    764         for line in lines(subviews: subviews, in: bounds.width) {
    765             let sizes = line.map { subviews[$0].sizeThatFits(.unspecified) }
    766             let lineWidth = sizes.reduce(0) { $0 + $1.width }
    767                 + horizontalSpacing * CGFloat(line.count - 1)
    768             let lineHeight = sizes.map(\.height).max() ?? 0
    769             var x = bounds.minX + (bounds.width - lineWidth) / 2
    770             for (offset, index) in line.enumerated() {
    771                 subviews[index].place(
    772                     at: CGPoint(x: x, y: y + (lineHeight - sizes[offset].height) / 2),
    773                     anchor: .topLeading,
    774                     proposal: .unspecified
    775                 )
    776                 x += sizes[offset].width + horizontalSpacing
    777             }
    778             y += lineHeight + verticalSpacing
    779         }
    780     }
    781 }
    782 
    783 /// A two-tone rendering of the grid shape — open squares light, blocks dark —
    784 /// drawn with `Canvas` so it scales crisply at any render size.
    785 private struct ShareGridSilhouette: View {
    786     let cells: [[Puzzle.Cell]]
    787     /// Per-cell author tints, parallel to `cells`; `nil` falls back to `openColor`.
    788     let tints: [[Color?]]
    789     let rows: Int
    790     let cols: Int
    791 
    792     private let blockColor = Color(white: 0.15)
    793     private let openColor = Color.white
    794     private let lineColor = Color(white: 0.78)
    795 
    796     var body: some View {
    797         Canvas { context, size in
    798             guard rows > 0, cols > 0 else { return }
    799             let cw = size.width / CGFloat(cols)
    800             let ch = size.height / CGFloat(rows)
    801             let line = max(1, min(cw, ch) * 0.06)
    802             // The gridline colour shows through the inset gaps between cells.
    803             context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(lineColor))
    804             for r in 0..<rows {
    805                 guard r < cells.count else { continue }
    806                 for c in 0..<cols {
    807                     guard c < cells[r].count else { continue }
    808                     let cellRect = CGRect(
    809                         x: CGFloat(c) * cw,
    810                         y: CGFloat(r) * ch,
    811                         width: cw,
    812                         height: ch
    813                     )
    814                     if cells[r][c].isBlock {
    815                         // Fill the whole cell so adjacent blocks merge into a
    816                         // solid region — Crossmate draws no gridlines between
    817                         // black squares.
    818                         context.fill(Path(cellRect), with: .color(blockColor))
    819                     } else {
    820                         // Inset so the gridline colour shows around open squares.
    821                         let tint = (r < tints.count && c < tints[r].count) ? tints[r][c] : nil
    822                         context.fill(
    823                             Path(cellRect.insetBy(dx: line / 2, dy: line / 2)),
    824                             with: .color(tint ?? openColor)
    825                         )
    826                     }
    827                 }
    828             }
    829         }
    830         .aspectRatio(CGFloat(cols) / CGFloat(rows), contentMode: .fit)
    831     }
    832 }