commit cc62a31d019dc3a62ffcb4a215cdc562a68ee8ee
parent eb0e9aa4055ea0e307ee1394df69e44016567580
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Apr 2026 13:37:27 +0900
Automate production of iPhone screenshots
This commit adds files necessary to create iPhone screenshots. This is
probably horribly overengineered given how infrequently the production
of screenshots is likely to be.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
9 files changed, 471 insertions(+), 1 deletion(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -50,6 +50,7 @@
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; };
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
+ 5D79B17FCA4C50C0CF08899A /* ListlessiOSScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D0F534D5B4D5ECF74CF6E6 /* ListlessiOSScreenshots.swift */; };
5E6BE0BA881F6CAEF455D9ED /* ListlessMacUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */; };
60FB7A1F3B2F037C655E10DB /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
@@ -180,6 +181,7 @@
114A89FD89C8EFB5771B7242 /* ItemStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemStoreCompletionTests.swift; sourceTree = "<group>"; };
126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
138DCA35ED82A745E4745175 /* ItemEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEntity.swift; sourceTree = "<group>"; };
+ 16D0F534D5B4D5ECF74CF6E6 /* ListlessiOSScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSScreenshots.swift; sourceTree = "<group>"; };
17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColor.swift; sourceTree = "<group>"; };
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
20A09A6C1C2251E96E1B5D96 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
@@ -290,6 +292,7 @@
20349AB212EAB4FB5F21D959 /* UI */ = {
isa = PBXGroup;
children = (
+ 16D0F534D5B4D5ECF74CF6E6 /* ListlessiOSScreenshots.swift */,
88C0D6F2667BD14F29CB84E5 /* ListlessiOSUITests.swift */,
EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */,
);
@@ -701,7 +704,6 @@
};
};
buildConfigurationList = CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */;
- compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
@@ -711,6 +713,7 @@
mainGroup = ED4862258A8A70025EE14416;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
+ productRefGroup = 3936BDEE64D16E6C4C85B3DD /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
@@ -887,6 +890,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 5D79B17FCA4C50C0CF08899A /* ListlessiOSScreenshots.swift in Sources */,
239F975836FD432A5FF04036 /* ListlessiOSUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift
@@ -73,6 +73,8 @@ struct ListlessiOSApp: App {
if isUITesting {
UserDefaults.standard.set(true, forKey: "didCompleteTutorial")
+ let theme = ProcessInfo.processInfo.arguments.contains("THEME_COLLAROY") ? 1 : 0
+ UserDefaults.standard.set(theme, forKey: "colorTheme")
}
}
diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift
@@ -470,6 +470,9 @@ struct ItemListView: View, ItemListViewProtocol {
.onAppear {
fState.focusedField = .scrollView
updateMenuCoordinator()
+ if ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SHOW_SETTINGS") {
+ iState.isShowingSettings = true
+ }
}
.onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
.onChange(of: undoManager, initial: true) { _, newValue in
diff --git a/ListlessiOS/Views/ItemRowView.swift b/ListlessiOS/Views/ItemRowView.swift
@@ -158,6 +158,13 @@ struct ItemRowView: View {
.onAppear {
editingTitle = item.title
cachedAccentColor = computeAccentColor()
+ if index == 0, !item.isCompleted,
+ ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SWIPE")
+ {
+ swipeOffset = 60
+ swipeDirection = .right
+ isSwipeTriggered = true
+ }
}
.onChange(of: item.title) { _, newValue in
if !isCurrentlyEditing {
diff --git a/Scripts/screenshots-iphone-compose.swift b/Scripts/screenshots-iphone-compose.swift
@@ -0,0 +1,109 @@
+#!/usr/bin/env swift
+
+import AppKit
+import CoreGraphics
+import CoreText
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 4 else {
+ fputs("Usage: compose-screenshot.swift <input.png> <output.png> <text>\n", stderr)
+ exit(1)
+}
+
+let inputPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let text = CommandLine.arguments[3]
+
+// Output dimensions (6.9" App Store)
+let canvasWidth: CGFloat = 1320
+let canvasHeight: CGFloat = 2868
+
+// Background: rgb(30, 16, 40)
+let bgColor = CGColor(red: 30.0 / 255.0, green: 16.0 / 255.0, blue: 40.0 / 255.0, alpha: 1.0)
+
+// Load framed device image
+guard let dataProvider = CGDataProvider(filename: inputPath),
+ let deviceImage = CGImage(
+ pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true,
+ intent: .defaultIntent)
+else {
+ fputs("Failed to load image: \(inputPath)\n", stderr)
+ exit(1)
+}
+
+let deviceWidth = CGFloat(deviceImage.width)
+let deviceHeight = CGFloat(deviceImage.height)
+
+// Create canvas
+guard
+ let context = CGContext(
+ data: nil,
+ width: Int(canvasWidth),
+ height: Int(canvasHeight),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+else {
+ fputs("Failed to create graphics context\n", stderr)
+ exit(1)
+}
+
+// Fill background
+context.setFillColor(bgColor)
+context.fill(CGRect(x: 0, y: 0, width: canvasWidth, height: canvasHeight))
+
+// Scale device image to fit width with padding, leaving space for text
+let bottomPadding: CGFloat = 60
+let sidePadding: CGFloat = 60
+let topReserved: CGFloat = 300
+let maxDeviceWidth = canvasWidth - sidePadding * 2
+let maxDeviceHeight = canvasHeight - bottomPadding - topReserved
+let scale = min(maxDeviceWidth / deviceWidth, maxDeviceHeight / deviceHeight, 1.0)
+let scaledWidth = deviceWidth * scale
+let scaledHeight = deviceHeight * scale
+let deviceX = (canvasWidth - scaledWidth) / 2
+let deviceY = bottomPadding // CG origin is bottom-left
+
+context.draw(
+ deviceImage, in: CGRect(x: deviceX, y: deviceY, width: scaledWidth, height: scaledHeight))
+
+// Draw text centered above device
+let fontSize: CGFloat = 72
+let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
+let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.white,
+]
+let attrString = NSAttributedString(string: text, attributes: attributes)
+let ctLine = CTLineCreateWithAttributedString(attrString)
+let textBounds = CTLineGetBoundsWithOptions(ctLine, .useOpticalBounds)
+
+let textAreaTop = canvasHeight
+let textAreaBottom = deviceY + scaledHeight
+let textX = (canvasWidth - textBounds.width) / 2 - textBounds.origin.x
+let textY = (textAreaTop + textAreaBottom) / 2 - textBounds.height / 2 - textBounds.origin.y
+
+context.textPosition = CGPoint(x: textX, y: textY)
+CTLineDraw(ctLine, context)
+
+// Save
+guard let resultImage = context.makeImage(),
+ let destination = CGImageDestinationCreateWithURL(
+ URL(fileURLWithPath: outputPath) as CFURL,
+ "public.png" as CFString,
+ 1,
+ nil
+ )
+else {
+ fputs("Failed to create output\n", stderr)
+ exit(1)
+}
+
+CGImageDestinationAddImage(destination, resultImage, nil)
+guard CGImageDestinationFinalize(destination) else {
+ fputs("Failed to write output\n", stderr)
+ exit(1)
+}
diff --git a/Scripts/screenshots-iphone-island.swift b/Scripts/screenshots-iphone-island.swift
@@ -0,0 +1,78 @@
+#!/usr/bin/env swift
+
+import CoreGraphics
+import Foundation
+import ImageIO
+
+guard CommandLine.arguments.count >= 2 else {
+ fputs("Usage: add-dynamic-island.swift <image.png> [output.png]\n", stderr)
+ exit(1)
+}
+
+let inputPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments.count >= 3 ? CommandLine.arguments[2] : inputPath
+
+guard let dataProvider = CGDataProvider(filename: inputPath),
+ let image = CGImage(
+ pngDataProviderSource: dataProvider, decode: nil, shouldInterpolate: true,
+ intent: .defaultIntent)
+else {
+ fputs("Failed to load image: \(inputPath)\n", stderr)
+ exit(1)
+}
+
+let width = CGFloat(image.width)
+let height = CGFloat(image.height)
+
+// Dynamic Island dimensions as proportions of screen width
+let pillWidth = width * 0.280
+let pillHeight = width * 0.062
+let pillY = width * 0.050
+let pillX = (width - pillWidth) / 2
+let cornerRadius = pillHeight / 2
+
+guard
+ let context = CGContext(
+ data: nil,
+ width: Int(width),
+ height: Int(height),
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ )
+else {
+ fputs("Failed to create graphics context\n", stderr)
+ exit(1)
+}
+
+// Draw original image
+context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
+
+// Draw Dynamic Island pill (CG origin is bottom-left, so flip Y)
+let flippedY = height - pillY - pillHeight
+let pillRect = CGRect(x: pillX, y: flippedY, width: pillWidth, height: pillHeight)
+let pillPath = CGPath(
+ roundedRect: pillRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
+
+context.setFillColor(CGColor(red: 0, green: 0, blue: 0, alpha: 1))
+context.addPath(pillPath)
+context.fillPath()
+
+guard let resultImage = context.makeImage(),
+ let destination = CGImageDestinationCreateWithURL(
+ URL(fileURLWithPath: outputPath) as CFURL,
+ "public.png" as CFString,
+ 1,
+ nil
+ )
+else {
+ fputs("Failed to create output\n", stderr)
+ exit(1)
+}
+
+CGImageDestinationAddImage(destination, resultImage, nil)
+guard CGImageDestinationFinalize(destination) else {
+ fputs("Failed to write output\n", stderr)
+ exit(1)
+}
diff --git a/Scripts/screenshots-iphone.sh b/Scripts/screenshots-iphone.sh
@@ -0,0 +1,133 @@
+#!/bin/bash
+set -euo pipefail
+
+DO_COMPOSE=false
+while getopts "c" opt; do
+ case $opt in
+ c) DO_COMPOSE=true ;;
+ *) ;;
+ esac
+done
+shift $((OPTIND - 1))
+
+MAJOR="${1:-26}"
+RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+ echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+fi
+
+DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPhone" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+
+if [ -z "$DEVICE" ]; then
+ echo "No available iPhone simulator found for iOS ${RUNTIME}." >&2
+ exit 1
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+
+# Boot simulator if needed
+UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "$DEVICE" \
+ | head -1 \
+ | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+
+xcrun simctl boot "$UDID" 2>/dev/null || true
+
+# Override status bar: 9:41, full signal, full battery
+xcrun simctl status_bar "$UDID" override \
+ --time "9:41" \
+ --batteryState discharging \
+ --batteryLevel 100 \
+ --wifiBars 3 \
+ --cellularBars 4 \
+ --cellularMode active \
+ --dataNetwork wifi
+
+echo "Status bar overridden"
+
+SCREENSHOT_TMP="/tmp/listless-screenshots"
+FRAMED_TMP="/tmp/listless-framed"
+MARKETING_DIR="$(pwd)/Marketing"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Screenshot captions for composing
+CAPTIONS=(
+ "Syncs via iCloud"
+ "Minimalist"
+ "Gesture Based"
+ "(Slightly) Customisable"
+)
+
+# Run screenshot tests (writes to SCREENSHOT_TMP)
+xcodebuild test \
+ -scheme "Listless iOS" \
+ -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+ -only-testing:"Listless iOS UI Tests/ListlessiOSScreenshots" \
+ 2>&1
+
+# Clear status bar override (ignore errors if simulator already shut down)
+xcrun simctl status_bar "$UDID" clear 2>/dev/null || true
+
+if ! ls "${SCREENSHOT_TMP}"/*.png 1>/dev/null 2>&1; then
+ echo "No screenshots found in ${SCREENSHOT_TMP}."
+ exit 1
+fi
+
+mkdir -p "${MARKETING_DIR}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ # Frame with Framous, then compose onto background with text
+ mkdir -p "${FRAMED_TMP}"
+
+ echo ""
+ echo "Framing screenshots..."
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ shortcuts run "Frame Screenshots" -i "$file" -o "${FRAMED_TMP}/framed_${n}.png"
+ echo " Framed screenshot ${n}"
+ n=$((n + 1))
+ done
+
+ echo "Composing final images..."
+ n=0
+ for file in "${FRAMED_TMP}"/framed_*.png; do
+ caption="${CAPTIONS[$n]:-}"
+ swift "${SCRIPT_DIR}/screenshots-iphone-compose.swift" "$file" "${MARKETING_DIR}/iphone_${n}.png" "$caption"
+ echo " iphone_${n}.png — ${caption}"
+ n=$((n + 1))
+ done
+
+ rm -rf "${FRAMED_TMP}"
+else
+ # Raw screenshots with Dynamic Island overlay
+ OUTPUT_DIR="$(pwd)"
+ echo ""
+ echo "Adding Dynamic Island..."
+ n=0
+ for file in "${SCREENSHOT_TMP}"/0*.png; do
+ swift "${SCRIPT_DIR}/screenshots-iphone-island.swift" "$file" "${OUTPUT_DIR}/iphone_${n}.png"
+ echo " iphone_${n}.png"
+ n=$((n + 1))
+ done
+fi
+
+rm -rf "${SCREENSHOT_TMP}"
+
+if [ "$DO_COMPOSE" = true ]; then
+ echo ""
+ echo "Screenshots saved to ${MARKETING_DIR}/"
+ ls -la "${MARKETING_DIR}"/iphone_*.png
+else
+ echo ""
+ echo "Screenshots saved to ${OUTPUT_DIR}/"
+ ls -la "${OUTPUT_DIR}"/iphone_*.png
+fi
diff --git a/Tests/UI/ListlessiOSScreenshots.swift b/Tests/UI/ListlessiOSScreenshots.swift
@@ -0,0 +1,133 @@
+import XCTest
+
+final class ListlessiOSScreenshots: XCTestCase {
+ var app: XCUIApplication!
+
+ static let screenshotDir = "/tmp/listless-screenshots"
+
+ override class func setUp() {
+ super.setUp()
+ try? FileManager.default.createDirectory(
+ atPath: screenshotDir,
+ withIntermediateDirectories: true
+ )
+ }
+
+ override func tearDownWithError() throws {
+ app.terminate()
+ }
+
+ // MARK: - Helpers
+
+ private func launchApp(_ additionalArgs: [String] = []) {
+ app = XCUIApplication()
+ app.launchArguments = ["UI_TESTING"] + additionalArgs
+ app.launch()
+ }
+
+ var draftTextField: XCUIElement {
+ app.textViews.matching(identifier: "draft-row-append").firstMatch
+ }
+
+ var itemListScrollView: XCUIElement {
+ app.scrollViews["item-list-scrollview"]
+ }
+
+ func createItem(_ title: String) {
+ let textView = draftTextField
+ if !textView.exists {
+ itemListScrollView.tap()
+ _ = textView.waitForExistence(timeout: 2)
+ }
+ textView.tap()
+ textView.typeText(title + "\n")
+ }
+
+ func itemCheckbox(at index: Int) -> XCUIElement {
+ app.buttons.matching(identifier: "item-checkbox").element(boundBy: index)
+ }
+
+ func exitEditingMode() {
+ let draft = draftTextField
+ if draft.exists {
+ draft.typeText("\n")
+ }
+ }
+
+ func saveScreenshot(name: String) {
+ let screenshot = XCUIScreen.main.screenshot()
+ let attachment = XCTAttachment(screenshot: screenshot)
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+
+ let data = screenshot.pngRepresentation
+ let path = "\(Self.screenshotDir)/\(name).png"
+ try? data.write(to: URL(fileURLWithPath: path))
+ }
+
+ // MARK: - Screenshots
+
+ /// Five items with keyboard up: three active, one empty draft row focused,
+ /// one completed at the bottom.
+ func testScreenshot01_ItemsWithKeyboard() throws {
+ launchApp()
+
+ createItem("Add items on your iPhone")
+ createItem("Edit items on your iPad")
+ createItem("Reorder items on your Mac")
+ createItem("Complete items on your Watch")
+ exitEditingMode()
+
+ usleep(500_000)
+
+ // Complete the last item
+ let checkbox = itemCheckbox(at: 3)
+ XCTAssertTrue(checkbox.waitForExistence(timeout: 2))
+ checkbox.tap()
+
+ usleep(500_000)
+
+ // Tap below items to create an empty focused draft row
+ let bottomArea = itemListScrollView.coordinate(
+ withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)
+ )
+ bottomArea.tap()
+ XCTAssertTrue(draftTextField.waitForExistence(timeout: 2))
+ usleep(300_000)
+
+ saveScreenshot(name: "01-items")
+ }
+
+ /// Empty list showing the "Pull down to create" hint.
+ func testScreenshot02_EmptyState() throws {
+ launchApp()
+
+ usleep(500_000)
+
+ saveScreenshot(name: "02-empty-state")
+ }
+
+ /// Two items with the first row swiped right past the complete threshold.
+ /// Collaroy colour theme.
+ func testScreenshot03_SwipeComplete() throws {
+ launchApp(["THEME_COLLAROY", "SCREENSHOT_SWIPE"])
+
+ createItem("Slide left to complete")
+ createItem("Slide right to delete")
+ exitEditingMode()
+
+ usleep(500_000)
+
+ saveScreenshot(name: "03-swipe")
+ }
+
+ /// Settings sheet.
+ func testScreenshot04_Settings() throws {
+ launchApp(["SCREENSHOT_SHOW_SETTINGS"])
+
+ usleep(500_000)
+
+ saveScreenshot(name: "04-settings")
+ }
+}
diff --git a/project.yml b/project.yml
@@ -185,6 +185,7 @@ targets:
- path: Tests/UI
includes:
- "ListlessiOSUITests.swift"
+ - "ListlessiOSScreenshots.swift"
dependencies:
- target: Listless iOS
settings: