crossmate

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

commit 589feeaea00a62dc8f2ffde711506883bfa1dc41
parent 412e38cdf3a44e290a3e4ab8e510af2cf6cd95f3
Author: Michael Camilleri <[email protected]>
Date:   Thu, 25 Jun 2026 01:59:19 +0900

Improve user-facing messages

Crossmate's failure and confirmation copy did not use consistent
terminology and could be unhelpful. For example, when a fetched puzzle
failed to parse, an alert titled 'Couldn't Create Game' presented
XD.ParseError's default localizedDescription.  If this occurred during a
random fetch, the user was left with no record of which date had been
chosen, so the failure could be neither understood nor reported.

This commit updates the user-facing messages to consistently use
'puzzle' rather than 'game'. It also fixes the parsing error to use the correct
terminology and log data that can identify the specific problem.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Models/TipStore.swift | 4++--
MCrossmate/Models/XD.swift | 35+++++++++++++++++++++++++++++++++++
MCrossmate/Sync/ShareController.swift | 6+++---
MCrossmate/Views/Browse/NewGameSheet.swift | 39+++++++++++++++++++++++++++++++++------
MCrossmate/Views/Friends/FriendsView.swift | 2+-
MCrossmate/Views/GameList/GameListView.swift | 2+-
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 6+++---
MCrossmate/Views/Settings/SettingsView.swift | 6+++---
8 files changed, 81 insertions(+), 19 deletions(-)

diff --git a/Crossmate/Models/TipStore.swift b/Crossmate/Models/TipStore.swift @@ -54,7 +54,7 @@ enum TipCatalog { Tip( id: "get-attention", title: "Announce Your Availability", - body: "Nudge your friends from the in-game Players menu in a shared puzzle." + body: "Nudge your friends from the Players menu in a shared puzzle." ), Tip( id: "be-notified", @@ -64,7 +64,7 @@ enum TipCatalog { Tip( id: "take-a-hint", title: "Get Unstuck in Difficult Puzzles", - body: "Use the in-game Hints menu to check your letters or get an answer." + body: "Use the Hints menu in a puzzle to check your letters or get an answer." ), Tip( id: "import-puzzles", diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -111,6 +111,32 @@ struct XD: Sendable { return ".xd grid cell at row \(row + 1), column \(col + 1) has no inferred solution" } } + + /// A non-technical sentence fragment for the user-facing alert, phrased + /// to follow "The puzzle for {date} …". The specifics that matter for a + /// bug report — the offending character, the malformed clue line, the + /// grid coordinates — stay out of this and ride in `description` to the + /// diagnostic log instead. + var userFacingReason: String { + switch self { + case .missingGrid: + return "is missing its grid" + case .missingClues: + return "is missing its clues" + case .raggedGrid: + return "has a grid Crossmate couldn't read" + case .malformedClue: + return "has a clue Crossmate couldn't read" + case .unknownGridCharacter: + return "contains a character Crossmate doesn't support" + case .clueAnswerMismatch: + return "has an answer that doesn't match its grid" + case .ambiguousClueAnswer: + return "has an answer Crossmate couldn't place in its grid" + case .missingInferredSolution: + return "has a square Crossmate couldn't solve" + } + } } static func parse(_ source: String) throws -> XD { @@ -218,6 +244,15 @@ struct XD: Sendable { } } + /// Reads a single metadata header value (e.g. `Date`, `Title`) from raw + /// `.xd` source without a full parse. Lets a caller name the puzzle that + /// failed to parse — the metadata section is independent of the grid/clue + /// sections that the parser rejects. + static func metadataValue(_ key: String, in source: String) -> String? { + guard let header = splitIntoSections(source).first else { return nil } + return parseMetadata(header).first(key) + } + private static func parseMetadata(_ lines: [String]) -> Metadata { var metadata = Metadata() for line in lines { diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -61,13 +61,13 @@ final class ShareController { var errorDescription: String? { switch self { case .gameNotFound: - "Game not found." + "Puzzle not found." case .invalidShareRecord: "Invalid share record." case .notAnOwner: - "Only the puzzle owner can share this game." + "Only the owner can share this puzzle." case .invalidGameRecord: - "Invalid game record." + "Invalid puzzle record." case .missingShareURL: "CloudKit did not return a share URL." case .collaborationLimitReached(let maxPeople): diff --git a/Crossmate/Views/Browse/NewGameSheet.swift b/Crossmate/Views/Browse/NewGameSheet.swift @@ -6,11 +6,18 @@ struct NewGameSheet: View { @Environment(\.dismiss) private var dismiss @Environment(NYTAuthService.self) private var nytAuth + @Environment(EventLog.self) private var eventLog @AppStorage("lastPuzzleSource") private var storedSourceRaw = PuzzleSource.bundles.rawValue @AppStorage("debugMode") private var debugMode = false @State private var selection: PuzzleSource = .bundles @State private var duplicateSource: String? - @State private var createError: String? + @State private var createError: CreateError? + + private struct CreateError: Identifiable { + let id = UUID() + let title: String + let message: String + } private var availableSources: [PuzzleSource] { var sources: [PuzzleSource] = [.bundles] @@ -86,10 +93,10 @@ struct NewGameSheet: View { } Button("Cancel", role: .cancel) {} } message: { _ in - Text("You already have a game for this puzzle. Cancel and resume it from the library, or create a new copy.") + Text("You already have a copy of this puzzle in your library. Do you want to create a new copy?") } .alert( - "Couldn't Create Game", + createError?.title ?? "Couldn't Create Puzzle", isPresented: .init( get: { createError != nil }, set: { if !$0 { createError = nil } } @@ -97,8 +104,8 @@ struct NewGameSheet: View { presenting: createError ) { _ in Button("OK", role: .cancel) {} - } message: { message in - Text(message) + } message: { error in + Text(error.message) } } @@ -115,8 +122,28 @@ struct NewGameSheet: View { let gameID = try store.createGame(from: source) dismiss() onCreated(gameID) + } catch let parseError as XD.ParseError { + // Keep the alert plain-language and date-stamped; the specifics that + // pin down the converter bug — which character, which clue — go to + // the diagnostic log, which is what a tester actually shares back. + let lead = XD.metadataValue("Date", in: source).map { "The puzzle for \($0)" } ?? "This puzzle" + createError = CreateError( + title: "Could Not Parse Puzzle", + message: "\(lead) \(parseError.userFacingReason)." + ) + eventLog.note( + "new puzzle parse failed [\(Self.puzzleDescriptor(from: source))]: \(parseError.description)", + level: "error" + ) } catch { - createError = error.localizedDescription + createError = CreateError(title: "Couldn't Create Puzzle", message: error.localizedDescription) } } + + /// A compact one-line identification of the puzzle for the diagnostic log, + /// built from whatever metadata headers survived in the raw source. + private static func puzzleDescriptor(from source: String) -> String { + let parts = ["Publisher", "Title", "Date"].compactMap { XD.metadataValue($0, in: source) } + return parts.isEmpty ? "unknown puzzle" : parts.joined(separator: " · ") + } } diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift @@ -75,7 +75,7 @@ struct FriendsView: View { Button("Cancel", role: .cancel) {} } message: { let name = blockTarget?.resolvedDisplayName ?? "this player" - Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") + Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.") } } } diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -219,7 +219,7 @@ struct GameListView: View { Button("Cancel", role: .cancel) {} } message: { let name = blockTarget?.resolvedInviterName ?? "this player" - Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") + Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.") } .alert("Set Profile Name", isPresented: $showingNamePrompt) { TextField("Name", text: $nameDraft) diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -244,18 +244,18 @@ struct PuzzleToolbarModifier: ViewModifier { private var puzzleDestructiveSection: some View { Section { - Button("Resign Game", role: .destructive) { + Button("Resign Puzzle", role: .destructive) { isConfirmingResign = true } .disabled(isSolved || !canResign) if session.mutator.isShared && !session.mutator.isOwned { - Button("Leave Game", role: .destructive) { + Button("Leave Puzzle", role: .destructive) { isConfirmingLeave = true } .disabled(shareController == nil) } else { - Button("Delete Game", role: .destructive) { + Button("Delete Puzzle", role: .destructive) { isConfirmingDelete = true } .disabled(!canDelete) diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -93,15 +93,15 @@ struct SettingsView: View { showResetConfirmation = true } .alert( - "Delete all games?", + "Delete all puzzles?", isPresented: $showResetConfirmation ) { - Button("Delete All Games", role: .destructive) { + Button("Delete All Puzzles", role: .destructive) { Task { await resetDatabase?() } } Button("Cancel", role: .cancel) {} } message: { - Text("This removes every game and clears the sync state on this device. Games on other devices and in iCloud are not affected.") + Text("This removes every puzzle and clears the sync state on this device. Puzzles on other devices and in iCloud are not affected.") } } }