Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ excluded:
- ios/epac/cabinet-positions.json
- ios/epacTests/__Snapshots__
- ios/fastlane
- ios/epacUITests/SnapshotHelper.swift

included:
- ios
Expand Down
61 changes: 40 additions & 21 deletions docs/marketing/screenshots/README.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,56 @@
# App Store Screenshots

EPAC-109 screenshot exports live here.
## Capture pipeline

Generate source captures with the App Store screenshot UI test:
Screenshots are captured natively on each target device using Fastlane Snapshot.
The Snapfile at `ios/fastlane/Snapfile` lists 4 devices:

```sh
APPSTORE_SCREENSHOT_DIR=/tmp/epac-appstore-screenshots \
xcodebuild test \
-project epac.xcodeproj \
-scheme epac \
-destination 'platform=iOS Simulator,name=EPAC App Store 16 Pro Max' \
-derivedDataPath ~/Library/Developer/Xcode/DerivedData/epac-afnetyeysumivgfzkpjhhfcnazhs \
-only-testing:epacUITests/epacUITests/testCaptureAppStoreScreenshotSources
```
- iPhone 16 Pro Max (6.9-inch, 1320×2868)
- iPhone 16 (6.1-inch, 1290×2796)
- iPad Pro 13-inch M4 (2064×2752)
- iPad Pro 11-inch M4 (1668×2388)

Render App Store-ready assets:
Each device captures 6 scenes via `AppStoreScreenshotTests.testCaptureAppStoreScreenshotSources`,
producing 24 PNGs total in `ios/fastlane/screenshots/en-CA/`.

### Running a capture

```sh
scripts/marketing/render_app_store_screenshots.sh /tmp/epac-appstore-screenshots docs/marketing/screenshots
cd ios && bundle exec fastlane snapshot
```

The capture route uses `Evidence.ScreenshotPlan`, the app's `-AppStoreScreenshots` launch argument, and official-record sample data from the House of Commons Hansard fixture already committed in the test suite. Resizing is delegated to `evidence resize` through `scripts/marketing/render_app_store_screenshots.sh`.
This launches the showcase UI (`AppStoreScreenshotShowcaseView`) with the
`-AppStoreScreenshots` and `-AppStoreScreenshotPage <0-5>` launch arguments on
each device. The showcase is fully offline — it uses bundled `NSLocalizedString`
keys and Color literals with no backend dependency.

On iPad simulators the showcase automatically renders a two-column layout
(headline/subtitle on the left, content card on the right) via
`horizontalSizeClass == .regular`, producing screenshots that are visually
distinct from the phone captures.

### Output

The render script writes:
Fastlane writes PNGs named `<Device>-<scene>.png` into
`ios/fastlane/screenshots/en-CA/`. The `clear_previous_screenshots(true)`
Snapfile directive removes stale files before each run.

- 6 iPhone 6.9-inch screenshots using the base scene filenames.
- 6 iPad Pro 13-inch screenshots prefixed with `APP_IPAD_PRO_3GEN_129_`.
- 6 iPad Pro 12.9-inch screenshots prefixed with `APP_IPAD_PRO_129_`.
### Refreshing the App Store upload set

To refresh the App Store upload directory from the committed marketing set:
Copy the Fastlane output into the delivery directory:

```sh
scripts/marketing/render_app_store_screenshots.sh docs/marketing/screenshots ios/fastlane/screenshots/en-CA
cp -f ios/fastlane/screenshots/en-CA/*.png docs/marketing/screenshots/
```

See `docs/marketing/evidence-workflows.md` for the full local workflow.
### Manual single-device capture

To capture a single device outside Fastlane:

```sh
xcodebuild test \
-project ios/epac.xcodeproj \
-scheme epac \
-destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4)' \
-only-testing:epacUITests/AppStoreScreenshotTests/testCaptureAppStoreScreenshotSources
```
101 changes: 78 additions & 23 deletions ios/epac/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,11 +645,22 @@ private enum AppStoreScreenshotSpec {
static let sourceBadgeFontSize: CGFloat = 11
static let sourceBadgeHorizontalPadding: CGFloat = 12
static let sourceBadgeVerticalPadding: CGFloat = 8

// iPad two-column layout
static let iPadHeadlineFontSize: CGFloat = 44
static let iPadSubtitleFontSize: CGFloat = 18
static let iPadColumnSpacing: CGFloat = 40
static let iPadContentPadding: CGFloat = 48
static let iPadTopPadding: CGFloat = 64
static let iPadPhoneFrameMaxWidth: CGFloat = 420
}

private struct AppStoreScreenshotShowcaseView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selection = Self.initialSelection()

private var isIPad: Bool { horizontalSizeClass == .regular }

private static func initialSelection() -> Int {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(of: "-AppStoreScreenshotPage"),
Expand Down Expand Up @@ -716,15 +727,30 @@ private struct AppStoreScreenshotShowcaseView: View {
}

var body: some View {
Group {
if isIPad {
iPadBody
} else {
phoneBody
}
}
.dynamicTypeSize(.large)
.preferredColorScheme(.light)
}

private var phoneBody: some View {
TabView(selection: $selection) {
ForEach(Array(pages.enumerated()), id: \.offset) { index, page in
AppStoreScreenshotPageView(page: page)
AppStoreScreenshotPageView(page: page, isIPad: false)
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.dynamicTypeSize(.large)
.preferredColorScheme(.light)
}

private var iPadBody: some View {
let page = pages[selection]
return AppStoreScreenshotPageView(page: page, isIPad: true)
}
}

Expand All @@ -746,6 +772,7 @@ private struct AppStoreScreenshotPage {

private struct AppStoreScreenshotPageView: View {
let page: AppStoreScreenshotPage
var isIPad = false

private func t(_ key: String) -> String {
NSLocalizedString(key, comment: "")
Expand All @@ -764,31 +791,59 @@ private struct AppStoreScreenshotPageView: View {
)
.ignoresSafeArea()

VStack(alignment: .leading, spacing: AppStoreScreenshotSpec.pageVerticalSpacing) {
VStack(alignment: .leading, spacing: AppStoreScreenshotSpec.titleVerticalSpacing) {
Text(page.headline)
.font(.system(size: AppStoreScreenshotSpec.headlineFontSize, weight: .heavy, design: .rounded))
.foregroundStyle(.primary)
.minimumScaleFactor(AppStoreScreenshotSpec.headlineMinimumScaleFactor)
.lineLimit(AppStoreScreenshotSpec.headlineLineLimit)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("appStoreScreenshotHeadline")
if isIPad {
iPadContent
} else {
phoneContent
}
}
}

Text(page.subtitle)
.font(.system(size: AppStoreScreenshotSpec.subtitleFontSize, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(AppStoreScreenshotSpec.subtitleLineLimit)
.fixedSize(horizontal: false, vertical: true)
}
private var phoneContent: some View {
VStack(alignment: .leading, spacing: AppStoreScreenshotSpec.pageVerticalSpacing) {
titleSection(headlineSize: AppStoreScreenshotSpec.headlineFontSize, subtitleSize: AppStoreScreenshotSpec.subtitleFontSize)
.padding(.horizontal, AppStoreScreenshotSpec.titleHorizontalPadding)

showcaseContent
.padding(.horizontal, AppStoreScreenshotSpec.showcaseHorizontalPadding)
showcaseContent
.padding(.horizontal, AppStoreScreenshotSpec.showcaseHorizontalPadding)

Spacer(minLength: AppStoreScreenshotSpec.bottomSpacerMinLength)
Spacer(minLength: AppStoreScreenshotSpec.bottomSpacerMinLength)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, AppStoreScreenshotSpec.pageTopPadding)
}

private var iPadContent: some View {
HStack(alignment: .top, spacing: AppStoreScreenshotSpec.iPadColumnSpacing) {
VStack(alignment: .leading, spacing: AppStoreScreenshotSpec.pageVerticalSpacing) {
titleSection(headlineSize: AppStoreScreenshotSpec.iPadHeadlineFontSize, subtitleSize: AppStoreScreenshotSpec.iPadSubtitleFontSize)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, AppStoreScreenshotSpec.pageTopPadding)
.frame(maxWidth: .infinity, alignment: .topLeading)

showcaseContent
.frame(maxWidth: AppStoreScreenshotSpec.iPadPhoneFrameMaxWidth)
}
.padding(AppStoreScreenshotSpec.iPadContentPadding)
.padding(.top, AppStoreScreenshotSpec.iPadTopPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}

private func titleSection(headlineSize: CGFloat, subtitleSize: CGFloat) -> some View {
VStack(alignment: .leading, spacing: AppStoreScreenshotSpec.titleVerticalSpacing) {
Text(page.headline)
.font(.system(size: headlineSize, weight: .heavy, design: .rounded))
.foregroundStyle(.primary)
.minimumScaleFactor(AppStoreScreenshotSpec.headlineMinimumScaleFactor)
.lineLimit(AppStoreScreenshotSpec.headlineLineLimit)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("appStoreScreenshotHeadline")

Text(page.subtitle)
.font(.system(size: subtitleSize, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(AppStoreScreenshotSpec.subtitleLineLimit)
.fixedSize(horizontal: false, vertical: true)
}
}

Expand Down
38 changes: 38 additions & 0 deletions ios/epacUITests/AppStoreScreenshotTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import XCTest

final class AppStoreScreenshotTests: XCTestCase {

private let sceneNames = [
"01-parliament-in-your-pocket",
"02-see-how-your-mp-votes",
"03-your-mp-everything-they-do",
"04-track-a-bill-start-to-finish",
"05-know-whos-influencing-your-mp",
"06-contact-them-in-one-tap"
]

override func setUpWithError() throws {
continueAfterFailure = false
}

func testCaptureAppStoreScreenshotSources() throws {
let app = XCUIApplication()
for (index, name) in sceneNames.enumerated() {
app.launchArguments = [
"-AppStoreScreenshots",
"-AppStoreScreenshotPage", "\(index)",
"-UIAnimationsDisabled", "YES"
]
setupSnapshot(app)
app.launch()

let headline = app.staticTexts["appStoreScreenshotHeadline"]
XCTAssertTrue(
headline.waitForExistence(timeout: 10),
"Showcase headline should appear for page \(index)"
)

snapshot(name, timeWaitingForIdle: 2)
}
}
}
Loading
Loading