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