crossmate

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

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:
MCrossmate/Views/Puzzle/SuccessPanel.swift | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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) + } +}