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 }