crossmate

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

commit 8b14ecb2be902030f1308945aa60ab7d66226861
parent a6f2a3c58b2ec66bfc1e550ba9afb40741aac504
Author: Michael Camilleri <[email protected]>
Date:   Mon,  6 Jul 2026 20:03:10 +0900

Support generating iPad marketing images

Diffstat:
MCrossmate/Views/MarketingScreenshots.swift | 334++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCrossmate/Views/Puzzle/PuzzleView.swift | 26+++++++++++++++++++++-----
MScripts/screenshots-ios-compose.swift | 8++++----
MScripts/screenshots-marketing.sh | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
4 files changed, 421 insertions(+), 28 deletions(-)

diff --git a/Crossmate/Views/MarketingScreenshots.swift b/Crossmate/Views/MarketingScreenshots.swift @@ -113,7 +113,12 @@ private struct MarketingPuzzleSceneView: View { private static func makeModel( scene: MarketingScene ) -> (session: PlayerSession, gameID: UUID, replayTimeline: ReplayTimeline?) { - let puzzle = try! Puzzle(xd: XD.parse(source)) + let puzzle: Puzzle + do { + puzzle = try Puzzle(xd: XD.parse(source)) + } catch { + fatalError("Marketing screenshot puzzle failed to parse: \(error)") + } let game = Game(puzzle: puzzle) let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")! let movesJournal = scene == .undoRedo @@ -246,7 +251,55 @@ private struct MarketingPuzzleSceneView: View { DANAE#ORES#ADES + A1. High voice ~ ALTO + A5. Fairy-tale brute ~ OGRE + A9. Pan player ~ PIPER + A14. Police action ~ RAID + A15. Park shade ~ TREE + A16. Turkish city ~ ADANA + A17. City transport, briefly ~ MUNI + A18. Garden tool user ~ HOER + A19. Rare, with a flourish ~ RAREE + A20. Investment legend's nickname ~ ORACLEOFOMAHA + A23. Fraternity letters ~ RAS + A24. Charity gifts ~ ALMS + A25. Studio apartments ~ ADOBES + A28. Pronoun pair ~ IT + A29. Black gold ~ OIL + A30. Charge ~ FEE + A31. Yoko of music ~ ONO + A32. Sci-fi hero Rogers ~ BUCK + A33. Rabbit, on screen ~ ROGER + A36. Scheme ~ PLAN A37. The best crossword app, perhaps ~ CROSSMATE + A40. Nin memoirist ~ ANAIS + A43. A little bit of data ~ ATA + A44. Wonderland girl ~ ALICE + A48. Noble title ~ SIRE + A49. Brings back ~ RESURRECTS + A51. Family boy ~ SON + A52. Public figure, briefly ~ POL + A53. Also ~ AND + A54. Bandit descriptor ~ ONEARM + A56. Animal charity initials ~ SPCA + A58. Pasture bleat ~ BAA + A61. Comic strip oath ~ LEAPINLIZARDS + A64. Island nation ~ HAITI + A66. Pods contents ~ PEAS + A67. Spots ~ SEES + A68. Mythic mother ~ DANAE + A69. Mine finds ~ ORES + A70. Fruity drinks ~ ADES + D1. Down entry ~ ARMORIB + D2. Down entry ~ LAURATU + D3. Down entry ~ TINAS + D4. Down entry ~ ODIC + D5. Down entry ~ OTHELLRO + D6. Down entry ~ GROOM + D7. Down entry ~ REEFS + D8. Down entry ~ EERO + D9. Down entry ~ PARADE + D10. Down entry ~ IDAHO """ } @@ -254,6 +307,37 @@ private struct MarketingImportScreenshotView: View { @State private var selection = PuzzleSource.imported var body: some View { + GeometryReader { proxy in + let isPad = UIDevice.current.userInterfaceIdiom == .pad + + ZStack { + if isPad { + MarketingGameListBackdrop() + } else { + Color(.systemGroupedBackground) + .ignoresSafeArea() + } + + sheetContent + .frame( + width: isPad ? min(proxy.size.width * 0.58, 760) : proxy.size.width, + height: isPad ? min(proxy.size.height * 0.86, 920) : proxy.size.height + ) + .clipShape(RoundedRectangle(cornerRadius: isPad ? 18 : 0, style: .continuous)) + .shadow(color: isPad ? Color.black.opacity(0.18) : .clear, radius: 22, y: 10) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background { + if isPad { + Color.black.opacity(0.08) + .ignoresSafeArea() + } + } + } + .preferredColorScheme(.light) + } + + private var sheetContent: some View { NavigationStack { VStack(spacing: 0) { Picker("Source", selection: $selection) { @@ -288,8 +372,254 @@ private struct MarketingImportScreenshotView: View { } } } - .preferredColorScheme(.light) } } +private struct MarketingGameListBackdrop: View { + private let columns = [GridItem(.adaptive(minimum: 380), spacing: 12)] + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 8) { + section("In Progress") { + LazyVGrid(columns: columns, spacing: 12) { + gameCard( + title: "Crossmate", + publisher: "Collaborative Crossword App", + updated: "Last updated 2 minutes ago", + isShared: true, + cells: Self.crossmateCells + ) + gameCard( + title: "Weekend Cryptic", + publisher: "Imported", + updated: "Last updated 1 hour ago", + isShared: false, + cells: Self.crypticCells + ) + } + } + + section("Completed") { + LazyVGrid(columns: columns, spacing: 12) { + gameCard( + title: "Morning Mini", + publisher: "Crossmate", + updated: "Last updated 3 hours ago", + isShared: false, + cells: Self.miniCells + ) + gameCard( + title: "Tournament Pack", + publisher: "Across Lite", + updated: "Last updated on 3 Jul 2026", + isShared: true, + cells: Self.tournamentCells + ) + } + } + } + .padding(.vertical, 8) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button {} label: { + Image(systemName: "gearshape") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button {} label: { + Image(systemName: "person.2") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button {} label: { + Image(systemName: "plus") + } + } + } + } + .disabled(true) + .ignoresSafeArea() + } + + private func section<Content: View>( + _ title: String, + @ViewBuilder content: () -> Content + ) -> some View { + Section { + content() + .padding(.horizontal) + } header: { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(.systemGroupedBackground)) + } + } + + private func gameCard( + title: String, + publisher: String, + updated: String, + isShared: Bool, + cells: [GameThumbnailCell] + ) -> some View { + HStack(spacing: 12) { + VStack(spacing: 0) { + GridThumbnailView(width: 15, height: 15, cells: cells, size: 60) + if isShared { + participantStrip + .frame(width: 60) + .padding(.top, 2) + } + } + .frame(width: 60) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(title) + .font(.headline) + .lineLimit(1) + if isShared { + Image(systemName: "person.2.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + Text(publisher) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(updated) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Image(systemName: "ellipsis.circle") + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + } + .padding(12) + .frame(maxWidth: .infinity) + .frame(height: CardMetrics.height) + .background( + Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius) + ) + } + + private var participantStrip: some View { + HStack(spacing: 0) { + Rectangle() + .fill(Color.blue) + Rectangle() + .fill(Color(.separator)) + .frame(width: 0.5) + Rectangle() + .fill(Color.red) + } + .frame(maxWidth: .infinity) + .frame(height: 6) + } + + private static func cells(_ rows: [String]) -> [GameThumbnailCell] { + rows.flatMap { row in + row.map { ch in + switch ch { + case "#": + .block + case "x", "X": + .filled + default: + .empty + } + } + } + } + + private static let crossmateCells = cells([ + "....#....#.....", + "....#....#.....", + "....#....#...##", + ".............##", + "...#....#......", + "..#...##...#...", + "....#.....#....", + "###xxxxxx...###", + ".....#...#.....", + "....#..........", + "...#...##...###", + "......#....#...", + "##.............", + ".....#....#....", + ".....#....#....", + ]) + + private static let crypticCells = cells([ + "....#....#.....", + "..#......#.....", + "......#.....#..", + ".#.......#.....", + ".....###.......", + "....#.....#....", + "...#.......#...", + "##....#....#...", + "...#.......#...", + "....#.....#....", + ".......###.....", + ".....#.......#.", + "..#.....#......", + ".....#......#..", + ".....#....#....", + ]) + + private static let miniCells = cells([ + ".....#....#....", + ".xxx.#....#....", + ".xxx.#....#....", + ".xxx...........", + ".....#....#....", + "##....#....#...", + ".....#....#....", + "....#....#.....", + "....#....#.....", + "..#....#....##.", + "....#....#.....", + "..........#....", + "....#....#.xxx.", + "....#....#.xxx.", + "....#....#.....", + ]) + + private static let tournamentCells = cells([ + "....#....#.....", + ".xxxx....#.....", + ".xxxx....#..##.", + ".xxxx..........", + "...#....#......", + "..#...##...#...", + "....#.....#....", + "###....xxxx.###", + ".....#...#.....", + "....#..........", + "...#...##...###", + "......#...xxxx.", + "##........xxxx.", + ".....#....xxxx.", + ".....#....#....", + ]) +} + #endif diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -85,6 +85,11 @@ struct PuzzleView: View { case portrait } + private var effectivePadLayout: PadLayout? { + guard UIDevice.current.userInterfaceIdiom == .pad else { return nil } + return padLayout ?? .portrait + } + private func swatchImage(for color: PlayerColor) -> Image { let tint = UIColor(color.tint) let base = UIImage(systemName: "circle.fill") ?? UIImage() @@ -128,7 +133,7 @@ struct PuzzleView: View { var body: some View { Group { - switch padLayout { + switch effectivePadLayout { case .landscape: landscapePadLayout case .portrait: @@ -338,8 +343,12 @@ struct PuzzleView: View { return } let newLayout: PadLayout? - if UIDevice.current.userInterfaceIdiom == .pad, size != .zero { - newLayout = size.width > size.height ? .landscape : .portrait + if UIDevice.current.userInterfaceIdiom == .pad { + if Self.activeWindowSceneOrientation?.isLandscape == true { + newLayout = .landscape + } else { + newLayout = size.width > size.height ? .landscape : .portrait + } } else { newLayout = nil } @@ -350,6 +359,13 @@ struct PuzzleView: View { } } + private static var activeWindowSceneOrientation: UIInterfaceOrientation? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .interfaceOrientation + } + private func performResign() { do { try onResign?() @@ -401,7 +417,7 @@ struct PuzzleView: View { roster: roster, title: titleParts.title, subtitle: titleParts.subtitle, - showsScoreboard: padLayout == nil, + showsScoreboard: effectivePadLayout == nil, shouldAutoRevealScoreboard: shouldAutoRevealScoreboard, gameID: session.mutator.gameID, isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, @@ -470,7 +486,7 @@ struct PuzzleView: View { .transition(.move(edge: .bottom)) } else if showsCustomKeyboard { ControlsView(height: controlsPanelHeight) { - KeyboardView(session: session, showsNavigationKeys: padLayout != nil) + KeyboardView(session: session, showsNavigationKeys: effectivePadLayout != nil) .opacity(isInputBlocked ? 0.4 : 1) .allowsHitTesting(!isInputBlocked) .animation(.easeInOut(duration: 0.3), value: isInputBlocked) diff --git a/Scripts/screenshots-ios-compose.swift b/Scripts/screenshots-ios-compose.swift @@ -23,9 +23,9 @@ let canvasHeight: CGFloat = CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868 let backgroundColor = CGColor( - red: 29.0 / 255.0, - green: 37.0 / 255.0, - blue: 48.0 / 255.0, + red: 228.0 / 255.0, + green: 242.0 / 255.0, + blue: 255.0 / 255.0, alpha: 1.0 ) @@ -84,7 +84,7 @@ let fontSize: CGFloat = isPad ? 118 : (isLandscape ? canvasHeight * 0.06 : canva let font = NSFont.systemFont(ofSize: fontSize, weight: .bold) let attributes: [NSAttributedString.Key: Any] = [ .font: font, - .foregroundColor: NSColor.white, + .foregroundColor: NSColor(calibratedRed: 17 / 255, green: 24 / 255, blue: 39 / 255, alpha: 1), ] let attrString = NSAttributedString(string: text, attributes: attributes) let ctLine = CTLineCreateWithAttributedString(attrString) diff --git a/Scripts/screenshots-marketing.sh b/Scripts/screenshots-marketing.sh @@ -25,10 +25,27 @@ case "$PLATFORM" in ;; esac +set_simulator_landscape_left() { + osascript \ + -e 'tell application "Simulator" to activate' \ + -e 'delay 0.2' \ + -e 'tell application "System Events" to tell process "Simulator" to click menu item "Landscape Left" of menu 1 of menu item "Orientation" of menu "Device" of menu bar 1' +} + +set_ipad_landscape() { + local udid="$1" + + open -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || true + sleep 1 + set_simulator_landscape_left + sleep 1 +} + if [ "$PLATFORM" = "iphone" ]; then select_simulator "$MAJOR" CANVAS_ARGS=() else + MARKETING_IPAD_NAME="Crossmate Marketing iPad" RUNTIME=$(xcrun simctl list runtimes available \ | grep "iOS ${MAJOR}\." \ | sed 's/.*iOS \([0-9.]*\).*/\1/' \ @@ -43,29 +60,50 @@ else echo "No available iOS ${MAJOR}.x simulator runtime found." >&2 exit 1 fi - DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \ - | grep "iPad Pro 13" \ + + UDID=$(xcrun simctl list devices \ + | grep -F "${MARKETING_IPAD_NAME} (" \ | head -1 \ - | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true) - if [ -z "$DEVICE" ]; then - DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \ - | grep "iPad" \ + | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/' || true) + if [ -n "$UDID" ]; then + DEVICE="$MARKETING_IPAD_NAME" + else + DEVICE_TYPE_LINE=$(xcrun simctl list devicetypes \ + | grep -F "iPad Pro 13-inch" \ + | grep -v "16GB" \ | head -1 \ - | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true) - fi - if [ -z "$DEVICE" ]; then - echo "No available iPad simulator found for iOS ${RUNTIME}." >&2 - exit 1 + || true) + if [ -z "$DEVICE_TYPE_LINE" ]; then + DEVICE_TYPE_LINE=$(xcrun simctl list devicetypes \ + | grep -F "iPad" \ + | head -1 \ + || true) + fi + DEVICE=$(sed -E 's/ \(com\.apple[^)]*\)$//' <<<"$DEVICE_TYPE_LINE") + DEVICE_TYPE_ID=$(sed -E 's/.*\((com\.apple[^)]*)\)$/\1/' <<<"$DEVICE_TYPE_LINE") + RUNTIME_ID=$(xcrun simctl list runtimes available \ + | grep "iOS ${RUNTIME} (" \ + | head -1 \ + | sed -E 's/.* - (com\.apple[^ ]*).*/\1/' || true) + if [ -z "$DEVICE_TYPE_ID" ] || [ -z "$RUNTIME_ID" ]; then + echo "Couldn't resolve a device type / runtime to create '${MARKETING_IPAD_NAME}'." >&2 + exit 1 + fi + echo "Creating '${MARKETING_IPAD_NAME}' (${DEVICE}, iOS ${RUNTIME})" + UDID=$(xcrun simctl create "${MARKETING_IPAD_NAME}" "${DEVICE_TYPE_ID}" "${RUNTIME_ID}") + DEVICE="$MARKETING_IPAD_NAME" fi - CANVAS_ARGS=(2064 2752) + CANVAS_ARGS=(2752 2064) fi echo "Using ${DEVICE}, iOS ${RUNTIME}" -UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \ - | grep "$DEVICE" \ - | head -1 \ - | sed 's/.*(\([A-F0-9-]*\)).*/\1/') +if [ -z "${UDID:-}" ]; then + UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \ + | grep "$DEVICE" \ + | head -1 \ + | sed 's/.*(\([A-F0-9-]*\)).*/\1/') +fi xcrun simctl boot "$UDID" 2>/dev/null || true xcrun simctl bootstatus "$UDID" -b @@ -98,11 +136,15 @@ CAPTIONS=( rm -rf "$DERIVED_DATA" "$WORK_DIR" mkdir -p "$WORK_DIR" "$MARKETING_DIR" +if [ "$PLATFORM" = "ipad" ]; then + set_ipad_landscape "$UDID" +fi + xcodebuild build \ -quiet \ -scheme "Crossmate" \ -project "${REPO_DIR}/Crossmate.xcodeproj" \ - -destination "platform=iOS Simulator,name=${DEVICE},OS=${DESTINATION_OS}" \ + -destination "id=${UDID}" \ -derivedDataPath "$DERIVED_DATA" APP_PATH="${DERIVED_DATA}/Build/Products/Debug-iphonesimulator/Crossmate.app" @@ -117,6 +159,7 @@ for index in "${!SCENES[@]}"; do scene="${SCENES[$index]}" caption="${CAPTIONS[$index]}" raw_path="${WORK_DIR}/${scene}.png" + rotated_path="${WORK_DIR}/${scene}-rotated.png" island_path="${WORK_DIR}/${scene}-island.png" framed_path="${WORK_DIR}/${scene}-framed.png" output_path="${MARKETING_DIR}/${PLATFORM}_${index}.png" @@ -139,6 +182,10 @@ for index in "${!SCENES[@]}"; do xcrun simctl io "$UDID" screenshot --mask ignored "$raw_path" frame_input="$raw_path" + if [ "$PLATFORM" = "ipad" ]; then + sips -r 270 "$raw_path" --out "$rotated_path" >/dev/null + frame_input="$rotated_path" + fi if [ "$PLATFORM" = "iphone" ] && [ "$ADD_DYNAMIC_ISLAND" = true ]; then swift "${SCRIPT_DIR}/screenshots-iphone-island.swift" "$raw_path" "$island_path" frame_input="$island_path"