commit 0d0d01f6d8ca5b683cd677b6c0055c7bddcb4253
parent 18b932134f080a7dfe639bfb4fafa556d5b76eb5
Author: Michael Camilleri <[email protected]>
Date: Sun, 28 Jun 2026 14:28:16 +0900
Add sharing to the Success Panel
The Success Panel now offers a share control so a player can post a
finished puzzle to a message thread or on social media. Tapping it opens
an in-app preview — the system share sheet's own header thumbnail is too
small to convey the result — that shows the rendered card and its
caption before either is handed to the system sheet.
The card is an ImageRenderer snapshot of SuccessShareCard: the grid
silhouette with open squares washed in their contributor's colour and
black squares drawn solid, the heading 'Completed <title>', the author
and solve time on one line, and a Crossmate wordmark. The silhouette
tints are keyed on authorship from the live session.game rather than the
move journal, which for a shared game finishes syncing long after the
win; a revealed square carries no author and so stays untinted. The
accompanying caption reads "I solved the puzzle '<title>' in <time> with
N friends using Crossmate", dropping each clause that does not apply.
An 'Include time' toggle re-renders both the card and the caption with
or without the solve time, and shows disabled when the game has no
recorded time to offer.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 318 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Views/Puzzle/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift
@@ -9,6 +9,11 @@ struct SuccessPanel: View {
/// (and again on "check again" while waiting on a peer's upload).
var loadReplay: (() async -> JournalReplayResult)? = nil
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(\.displayScale) private var displayScale
+ /// The rendered completion card, set when the user taps Share. Carrying the
+ /// image on the sheet's `item` (rather than a separate flag) guarantees it's
+ /// present the first time the sheet opens.
+ @State private var sharePreview: SharePreviewItem?
private struct Contribution: Identifiable {
let authorID: String?
@@ -57,6 +62,78 @@ struct SuccessPanel: View {
return "Finished in \(TimeLog.clockString(seconds))"
}
+ /// Co-players in the roster other than the local solver. Drives the
+ /// "with N friends" clause in the share text; 0 for a solo solve.
+ private var friendCount: Int {
+ roster.entries.filter { !$0.isLocal }.count
+ }
+
+ /// Whether a solve time is available to share at all (an archive made
+ /// before the clock existed has none).
+ private var hasClock: Bool { roster.solveTime() > 0 }
+
+ /// The caption handed to the share sheet alongside the rendered card:
+ /// "I solved the puzzle '<title>' in <time> with N friends using Crossmate",
+ /// dropping the time clause when none is recorded or the user opted it out,
+ /// and the friends clause when solo.
+ private func shareMessage(includeClock: Bool) -> String {
+ var parts = ["I solved the puzzle '\(session.puzzle.title)'"]
+ let seconds = roster.solveTime()
+ if includeClock, seconds > 0 {
+ parts.append("in \(TimeLog.clockString(seconds))")
+ }
+ if friendCount > 0 {
+ parts.append("with \(friendCount) friend\(friendCount == 1 ? "" : "s")")
+ }
+ return parts.joined(separator: " ") + " using Crossmate"
+ }
+
+ /// The tint for a filled square, using the active-word highlight fill. Keyed
+ /// on authorship, not the reveal mark: a revealed square clears its author
+ /// (Game.revealCells), and that nil author survives persistence even when
+ /// the `.revealed` mark doesn't — so an unauthored square is reliably left
+ /// untinted on both live and reloaded games. In a non-shared puzzle an
+ /// authored square is the user's own colour; in a shared puzzle it's the
+ /// contributing author's colour.
+ private func contributorTint(for authorID: String?) -> Color? {
+ guard let authorID else { return nil }
+ let hasRemotePlayers = roster.entries.contains { !$0.isLocal }
+ guard hasRemotePlayers else { return preferences.color.highlightFill }
+ return roster.entries.first(where: { $0.authorID == authorID })?.color.highlightFill
+ }
+
+ /// Per-cell tints for the share card's silhouette: `nil` for blocks, for
+ /// revealed squares, and for squares with no attributable author; otherwise
+ /// the contributor's colour. Reads the live `session.game` — the locally
+ /// authoritative grid that carries entries, marks (including `.revealed`),
+ /// and authors promptly, without depending on the journal/replay, which for
+ /// a shared game only finishes syncing long after completion. The card
+ /// renders on tap, by which point any resignation reveal has refreshed the
+ /// live game (the grid already shows the filled answers).
+ private var shareCellTints: [[Color?]] {
+ (0..<session.puzzle.height).map { r in
+ (0..<session.puzzle.width).map { c -> Color? in
+ guard !session.puzzle.cells[r][c].isBlock else { return nil }
+ let square = session.game.squares[r][c]
+ guard !square.mark.isRevealed else { return nil }
+ return contributorTint(for: square.letterAuthorID)
+ }
+ }
+ }
+
+ @MainActor private func makeShareImage(includeClock: Bool) -> Image? {
+ let timeText = (includeClock && hasClock) ? TimeLog.clockString(roster.solveTime()) : nil
+ let card = SuccessShareCard(
+ puzzle: session.puzzle,
+ cellTints: shareCellTints,
+ timeText: timeText
+ )
+ let renderer = ImageRenderer(content: card)
+ renderer.scale = displayScale
+ guard let uiImage = renderer.uiImage else { return nil }
+ return Image(uiImage: uiImage)
+ }
+
private var contributions: [Contribution] {
let replayCells = replay?.frame?.cells
var counts: [String?: Int] = [:]
@@ -186,6 +263,38 @@ struct SuccessPanel: View {
}
}
+ private var shareIcon: some View {
+ Image(systemName: "square.and.arrow.up")
+ .font(.system(size: 18, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(width: 32, height: 32)
+ .accessibilityLabel("Share")
+ }
+
+ private var shareButton: some View {
+ // Render on tap, not on appear, so the card captures the loaded replay
+ // frame (the authoritative final grid) rather than the empty pre-load
+ // state. Present our own preview first — the system share sheet's header
+ // thumbnail is always small.
+ Button {
+ if let image = makeShareImage(includeClock: true) {
+ sharePreview = SharePreviewItem(
+ title: session.puzzle.title,
+ initialImage: image,
+ hasClock: hasClock,
+ renderImage: { makeShareImage(includeClock: $0) },
+ messageFor: { shareMessage(includeClock: $0) }
+ )
+ }
+ } label: {
+ shareIcon
+ }
+ .buttonStyle(.plain)
+ .sheet(item: $sharePreview) { preview in
+ SharePreviewSheet(item: preview)
+ }
+ }
+
private var scoreboard: some View {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .center, spacing: 8) {
@@ -238,15 +347,18 @@ struct SuccessPanel: View {
}
}
- VStack(spacing: 4) {
- if let finishedInText {
- Text(finishedInText)
+ VStack(spacing: 8) {
+ VStack(spacing: 4) {
+ if let finishedInText {
+ Text(finishedInText)
+ }
+ Text(revealedSquaresText)
}
- Text(revealedSquaresText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ shareButton
}
- .font(.footnote)
- .foregroundStyle(.secondary)
- .padding(.top, 16)
+ .padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .center)
}
}
@@ -257,7 +369,7 @@ struct SuccessPanel: View {
// capped block within the column.
.frame(maxWidth: 320, alignment: .leading)
.frame(maxWidth: .infinity)
- .padding(.top, 8)
+ .padding(.top, 14)
}
.padding(.leading, 18)
.padding(.trailing, 24)
@@ -419,3 +531,201 @@ private struct PlaybackControl: View {
.accessibilityValue(isPlaying ? "Playing at speed \(selectedSpeed)" : "Paused")
}
}
+
+/// Drives `.sheet(item:)`. Carries the initial rendered card (so the sheet
+/// never opens empty) plus closures to re-render the card and rebuild the
+/// caption when the "Include time" toggle changes.
+private struct SharePreviewItem: Identifiable {
+ let id = UUID()
+ let title: String
+ let initialImage: Image
+ /// Whether a solve time exists to offer the toggle for at all.
+ let hasClock: Bool
+ let renderImage: @MainActor (Bool) -> Image?
+ let messageFor: (Bool) -> String
+}
+
+/// A confirmation sheet that shows the rendered card at full size with its
+/// caption and a Share button. Stands in for the system share sheet's tiny
+/// preview header so the user can see exactly what they're about to share.
+/// When a solve time exists, a toggle re-renders the card with or without it.
+private struct SharePreviewSheet: View {
+ let item: SharePreviewItem
+ @Environment(\.dismiss) private var dismiss
+ @State private var includeClock: Bool
+ @State private var image: Image
+
+ init(item: SharePreviewItem) {
+ self.item = item
+ _image = State(initialValue: item.initialImage)
+ // Off when there's no time to share; the toggle still shows, disabled.
+ _includeClock = State(initialValue: item.hasClock)
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 20) {
+ ScrollView {
+ VStack(spacing: 16) {
+ image
+ .resizable()
+ .scaledToFit()
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(.separator, lineWidth: 0.5)
+ )
+ Text(item.messageFor(includeClock))
+ .font(.callout)
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+
+ Toggle("Include time", isOn: $includeClock)
+ .disabled(!item.hasClock)
+ .padding(.horizontal)
+
+ ShareLink(
+ item: image,
+ subject: Text(item.title),
+ message: Text(item.messageFor(includeClock)),
+ preview: SharePreview(item.title, image: image)
+ ) {
+ Label("Share", systemImage: "square.and.arrow.up")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .padding([.horizontal, .bottom])
+ }
+ .navigationTitle("Share")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ .onChange(of: includeClock) { _, newValue in
+ if let updated = item.renderImage(newValue) { image = updated }
+ }
+ }
+ .presentationDetents([.large])
+ }
+}
+
+/// The shareable completion card, snapshotted by `ImageRenderer` and handed to
+/// the share sheet. Uses explicit colours (not the environment) so the exported
+/// image reads the same regardless of the device's light/dark setting.
+private struct SuccessShareCard: View {
+ let puzzle: Puzzle
+ /// Per-cell author tints, parallel to `puzzle.cells`; `nil` draws neutral.
+ let cellTints: [[Color?]]
+ let timeText: String?
+
+ /// Fixed render width; the grid sizes itself to the puzzle's aspect ratio.
+ private static let width: CGFloat = 480
+
+ var body: some View {
+ VStack(spacing: 20) {
+ HStack(spacing: 6) {
+ Text("Solved with")
+ Image("AboutIcon")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 22, height: 22)
+ .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
+ Text("Crossmate")
+ }
+ .font(.system(size: 16, weight: .regular, design: .rounded))
+ .foregroundStyle(.secondary)
+
+ ShareGridSilhouette(
+ cells: puzzle.cells,
+ tints: cellTints,
+ rows: puzzle.height,
+ cols: puzzle.width
+ )
+ .frame(maxWidth: 320)
+ .padding(.vertical, 12)
+
+ VStack(spacing: 8) {
+ Text("Completed \(puzzle.title)")
+ .font(.system(size: 24, weight: .semibold, design: .rounded))
+ .foregroundStyle(.black)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+
+ if puzzle.author != nil || timeText != nil {
+ HStack(spacing: 8) {
+ if let author = puzzle.author {
+ Text("By \(author)")
+ .lineLimit(1)
+ }
+ if let timeText {
+ Text("in \(timeText)")
+ .monospacedDigit()
+ }
+ }
+ .font(.system(size: 20, weight: .medium, design: .rounded))
+ .foregroundStyle(.black)
+ }
+ }
+ }
+ .padding(36)
+ .frame(width: Self.width)
+ .background(Color.white)
+ }
+}
+
+/// A two-tone rendering of the grid shape — open squares light, blocks dark —
+/// drawn with `Canvas` so it scales crisply at any render size.
+private struct ShareGridSilhouette: View {
+ let cells: [[Puzzle.Cell]]
+ /// Per-cell author tints, parallel to `cells`; `nil` falls back to `openColor`.
+ let tints: [[Color?]]
+ let rows: Int
+ let cols: Int
+
+ private let blockColor = Color(white: 0.15)
+ private let openColor = Color.white
+ private let lineColor = Color(white: 0.78)
+
+ var body: some View {
+ Canvas { context, size in
+ guard rows > 0, cols > 0 else { return }
+ let cw = size.width / CGFloat(cols)
+ let ch = size.height / CGFloat(rows)
+ let line = max(1, min(cw, ch) * 0.06)
+ // The gridline colour shows through the inset gaps between cells.
+ context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(lineColor))
+ for r in 0..<rows {
+ guard r < cells.count else { continue }
+ for c in 0..<cols {
+ guard c < cells[r].count else { continue }
+ let cellRect = CGRect(
+ x: CGFloat(c) * cw,
+ y: CGFloat(r) * ch,
+ width: cw,
+ height: ch
+ )
+ if cells[r][c].isBlock {
+ // Fill the whole cell so adjacent blocks merge into a
+ // solid region — Crossmate draws no gridlines between
+ // black squares.
+ context.fill(Path(cellRect), with: .color(blockColor))
+ } else {
+ // Inset so the gridline colour shows around open squares.
+ let tint = (r < tints.count && c < tints[r].count) ? tints[r][c] : nil
+ context.fill(
+ Path(cellRect.insetBy(dx: line / 2, dy: line / 2)),
+ with: .color(tint ?? openColor)
+ )
+ }
+ }
+ }
+ }
+ .aspectRatio(CGFloat(cols) / CGFloat(rows), contentMode: .fit)
+ }
+}