crossmate

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

MarketingScreenshots.swift (10538B)


      1 import SwiftUI
      2 
      3 #if DEBUG
      4 
      5 /// Launch-argument helpers shared by the app root and the Game List so the
      6 /// marketing "import" scene can drive the real New Puzzle flow instead of a
      7 /// mock. DEBUG-only, mirroring the rest of this file.
      8 enum MarketingLaunch {
      9     static var isScreenshot: Bool {
     10         ProcessInfo.processInfo.arguments.contains("--crossmate-marketing-screenshot")
     11     }
     12 
     13     /// The import scene renders the real Game List + New Puzzle sheet at the
     14     /// app root rather than routing through `MarketingScreenshotView`.
     15     static var isImportScene: Bool {
     16         isScreenshot && MarketingScene.current == .import
     17     }
     18 }
     19 
     20 @MainActor
     21 struct MarketingScreenshotView: View {
     22     private let services: AppServices
     23     private let scene: MarketingScene
     24 
     25     init(services: AppServices) {
     26         self.services = services
     27         self.scene = MarketingScene.current
     28     }
     29 
     30     var body: some View {
     31         switch scene {
     32         case .together, .solo, .replay, .undoRedo:
     33             MarketingPuzzleSceneView(scene: scene, services: services)
     34         case .import:
     35             // The import scene renders the real Game List + New Puzzle sheet at
     36             // the app root (see `CrossmateApp`); it never routes through here.
     37             Color(.systemBackground)
     38         }
     39     }
     40 }
     41 
     42 private enum MarketingScene: String {
     43     case together
     44     case solo
     45     case `import`
     46     case replay
     47     case undoRedo = "undo-redo"
     48 
     49     static var current: MarketingScene {
     50         let arguments = ProcessInfo.processInfo.arguments
     51         guard let flagIndex = arguments.firstIndex(of: "--crossmate-marketing-scene"),
     52               arguments.indices.contains(flagIndex + 1),
     53               let scene = MarketingScene(rawValue: arguments[flagIndex + 1])
     54         else {
     55             return .together
     56         }
     57         return scene
     58     }
     59 
     60     var isShared: Bool {
     61         switch self {
     62         case .together, .replay:
     63             true
     64         case .solo, .import, .undoRedo:
     65             false
     66         }
     67     }
     68 
     69 }
     70 
     71 private struct MarketingPuzzleSceneView: View {
     72     @State private var session: PlayerSession
     73     @State private var navigationPath: [UUID]
     74     private let scene: MarketingScene
     75     private let roster: PlayerRoster
     76     private let gameID: UUID
     77     private let replayTimeline: ReplayTimeline?
     78 
     79     init(scene: MarketingScene, services: AppServices) {
     80         let model = Self.makeModel(scene: scene)
     81         self.scene = scene
     82         _session = State(initialValue: model.session)
     83         _navigationPath = State(initialValue: [model.gameID])
     84         self.gameID = model.gameID
     85         self.replayTimeline = model.replayTimeline
     86 
     87         let remoteSelection = scene.isShared
     88             ? PlayerRoster.RemoteSelection(
     89                 authorID: "marketing-teammate",
     90                 row: 6,
     91                 col: 11,
     92                 direction: .down,
     93                 color: .red,
     94                 updatedAt: Date()
     95             )
     96             : nil
     97         self.roster = PlayerRoster(
     98             previewGameID: model.gameID,
     99             localName: services.preferences.name,
    100             localColor: services.preferences.color,
    101             remoteSelection: remoteSelection
    102         )
    103     }
    104 
    105     var body: some View {
    106         NavigationStack(path: $navigationPath) {
    107             Color(.systemBackground)
    108                 .navigationDestination(for: UUID.self) { destination in
    109                     if destination == gameID {
    110                         PuzzleView(
    111                             session: session,
    112                             roster: roster,
    113                             loadReplay: loadReplay
    114                         )
    115                         .navigationTitle("")
    116                         .navigationBarTitleDisplayMode(.inline)
    117                     }
    118                 }
    119         }
    120         .preferredColorScheme(.light)
    121     }
    122 
    123     private var loadReplay: () async -> JournalReplayResult {
    124         {
    125             guard let replayTimeline else { return .unavailable }
    126             return .ready(replayTimeline)
    127         }
    128     }
    129 
    130     private static func makeModel(
    131         scene: MarketingScene
    132     ) -> (session: PlayerSession, gameID: UUID, replayTimeline: ReplayTimeline?) {
    133         let puzzle: Puzzle
    134         do {
    135             puzzle = try Puzzle(xd: XD.parse(source))
    136         } catch {
    137             fatalError("Marketing screenshot puzzle failed to parse: \(error)")
    138         }
    139         let game = Game(puzzle: puzzle)
    140         let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")!
    141         let movesJournal = scene == .undoRedo
    142             ? MovesJournal(persistence: PersistenceController(inMemory: true))
    143             : nil
    144         let mutator = GameMutator(
    145             game: game,
    146             gameID: gameID,
    147             movesUpdater: nil,
    148             movesJournal: movesJournal,
    149             authorIDProvider: { "marketing-local" },
    150             isShared: scene.isShared,
    151             isCompleted: scene == .replay
    152         )
    153         let session = PlayerSession(game: game, mutator: mutator)
    154 
    155         if scene == .replay {
    156             seedReplayStart(game: game)
    157         } else if scene != .undoRedo {
    158             seedPuzzleStart(game: game)
    159         }
    160 
    161         session.direction = .across
    162         session.selectedRow = 7
    163         session.selectedCol = scene == .undoRedo ? 7 : 10
    164 
    165         if scene == .undoRedo {
    166             session.selectedCol = 3
    167             for letter in Array("CROSSMA") {
    168                 session.enter(String(letter))
    169             }
    170             session.undo()
    171         }
    172 
    173         return (
    174             session,
    175             gameID,
    176             scene == .replay ? replayTimeline(for: puzzle) : nil
    177         )
    178     }
    179 
    180     private static func seedPuzzleStart(game: Game) {
    181         for (offset, letter) in Array("CROSSMA").enumerated() {
    182             game.setLetter(String(letter), atRow: 7, atCol: 3 + offset, pencil: false)
    183         }
    184     }
    185 
    186     private static func seedReplayStart(game: Game) {
    187         game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "marketing-local")
    188         game.setLetter("L", atRow: 0, atCol: 1, pencil: false, authorID: "marketing-teammate")
    189         for (offset, letter) in Array("CROSSMATE").enumerated() {
    190             let authorID = offset.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
    191             game.setLetter(String(letter), atRow: 7, atCol: 3 + offset, pencil: false, authorID: authorID)
    192         }
    193     }
    194 
    195     private static func replayTimeline(for puzzle: Puzzle) -> ReplayTimeline {
    196         let earlyMoves: [(row: Int, col: Int, letter: Character, authorID: String)] =
    197             [
    198                 (0, 0, "A", "marketing-local"),
    199                 (0, 1, "L", "marketing-teammate"),
    200             ] + Array("CROSSMATE").enumerated().map { offset, letter in
    201                 (
    202                     row: 7,
    203                     col: 3 + offset,
    204                     letter: letter,
    205                     authorID: offset.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
    206                 )
    207             }
    208         var moves = earlyMoves
    209         for row in puzzle.cells {
    210             for cell in row where !cell.isBlock {
    211                 guard let solution = cell.solution,
    212                       !moves.contains(where: { $0.row == cell.row && $0.col == cell.col })
    213                 else { continue }
    214                 guard let letter = solution.uppercased().first else { continue }
    215                 let index = moves.count
    216                 moves.append((
    217                     row: cell.row,
    218                     col: cell.col,
    219                     letter: letter,
    220                     authorID: index.isMultiple(of: 2) ? "marketing-local" : "marketing-teammate"
    221                 ))
    222             }
    223         }
    224         let start = Date(timeIntervalSinceReferenceDate: 800_000_000)
    225         let entries = moves.enumerated().map { offset, move in
    226             JournalValue(
    227                 seq: Int64(offset + 1),
    228                 timestamp: start.addingTimeInterval(Double(offset) * 1.8),
    229                 position: GridPosition(row: move.row, col: move.col),
    230                 beforeState: .empty,
    231                 state: JournalCellState(
    232                     letter: String(move.letter),
    233                     mark: .none,
    234                     cellAuthorID: move.authorID
    235                 ),
    236                 actingAuthorID: move.authorID,
    237                 kind: .input,
    238                 targetSeq: nil,
    239                 batchID: nil,
    240                 prevSeqAtCell: nil,
    241                 direction: .across
    242             )
    243         }
    244         return ReplayTimeline(merging: [entries])
    245     }
    246 
    247     private static let source = """
    248     Title: Crossmate
    249     Publisher: Collaborative Crossword App for iOS & iPadOS
    250     Author: Crossmate
    251 
    252 
    253     ALTO#OGRE#PIPER
    254     RAID#TREE#ADANA
    255     MUNI#HOER#RAREE
    256     ORACLEOFOMAHA##
    257     RAS#ALMS#ADOBES
    258     IT#OIL##FEE#ONO
    259     BUCK#ROGER#PLAN
    260     ###CROSSMATE###
    261     ANAIS#ATA#ALICE
    262     SIRE#RESURRECTS
    263     SON#POL##AND###
    264     ONEARM#SPCA#BAA
    265     ##LEAPINLIZARDS
    266     HAITI#PEAS#SEES
    267     DANAE#ORES#ADES
    268 
    269 
    270     A1. High voice ~ ALTO
    271     A5. Fairy-tale brute ~ OGRE
    272     A9. Pan player ~ PIPER
    273     A14. Police action ~ RAID
    274     A15. Park shade ~ TREE
    275     A16. Turkish city ~ ADANA
    276     A17. City transport, briefly ~ MUNI
    277     A18. Garden tool user ~ HOER
    278     A19. Rare, with a flourish ~ RAREE
    279     A20. Investment legend's nickname ~ ORACLEOFOMAHA
    280     A23. Fraternity letters ~ RAS
    281     A24. Charity gifts ~ ALMS
    282     A25. Studio apartments ~ ADOBES
    283     A28. Pronoun pair ~ IT
    284     A29. Black gold ~ OIL
    285     A30. Charge ~ FEE
    286     A31. Yoko of music ~ ONO
    287     A32. Sci-fi hero Rogers ~ BUCK
    288     A33. Rabbit, on screen ~ ROGER
    289     A36. Scheme ~ PLAN
    290     A37. The best crossword app, perhaps ~ CROSSMATE
    291     A40. Nin memoirist ~ ANAIS
    292     A43. A little bit of data ~ ATA
    293     A44. Wonderland girl ~ ALICE
    294     A48. Noble title ~ SIRE
    295     A49. Brings back ~ RESURRECTS
    296     A51. Family boy ~ SON
    297     A52. Public figure, briefly ~ POL
    298     A53. Also ~ AND
    299     A54. Bandit descriptor ~ ONEARM
    300     A56. Animal charity initials ~ SPCA
    301     A58. Pasture bleat ~ BAA
    302     A61. Comic strip oath ~ LEAPINLIZARDS
    303     A64. Island nation ~ HAITI
    304     A66. Pods contents ~ PEAS
    305     A67. Spots ~ SEES
    306     A68. Mythic mother ~ DANAE
    307     A69. Mine finds ~ ORES
    308     A70. Fruity drinks ~ ADES
    309     D1. Down entry ~ ARMORIB
    310     D2. Down entry ~ LAURATU
    311     D3. Down entry ~ TINAS
    312     D4. Down entry ~ ODIC
    313     D5. Down entry ~ OTHELLRO
    314     D6. Down entry ~ GROOM
    315     D7. Down entry ~ REEFS
    316     D8. Down entry ~ EERO
    317     D9. Down entry ~ PARADE
    318     D10. Down entry ~ IDAHO
    319     """
    320 }
    321 
    322 #endif