Add App Store screenshot harness + listing metadata

DEBUG-only screenshot support (seeded sample data via ScreenshotSeed, per-screen
launch args, screenshot roots for both apps) so iPhone + Apple Watch App Store
shots can be captured deterministically from the simulator — all excluded from
release builds. Also seed ExerciseView's set-grid progress in init so it renders
correctly on the first frame. Adds Scripts/metadata/ as the listing source of
truth (copy, URLs, review notes, and the captured screenshots).

Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
This commit is contained in:
2026-06-19 19:23:54 -04:00
parent 1d856b98d0
commit 84d45a6d41
26 changed files with 313 additions and 16 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to this project are documented here.
## June 2026
- Exercise detail now renders the set-progress grid correctly on the first frame
(seeded from the log in `init`) instead of filling in a frame later.
- Added a DEBUG-only screenshot harness (seeded sample data + `--screenshot
--screen <name>` launch args, excluded from release builds) for generating App
Store screenshots from the iPhone and Apple Watch simulators, plus the
`Scripts/metadata/` App Store listing source of truth.
- Redesigned the Apple Watch app into a focused workout runner: it opens directly
on the active workout's exercise list (or prompts you to start one on iPhone),
and each exercise runs as a horizontally-paged HIIT cycle — a count-up work
+4
View File
@@ -0,0 +1,4 @@
{
"primary": "HEALTH_AND_FITNESS",
"secondary": null
}
+1
View File
@@ -0,0 +1 @@
Workouts Plus
@@ -0,0 +1 @@
https://indie.rzen.dev/apps/workouts/privacy
+1
View File
@@ -0,0 +1 @@
Splits you run from your wrist
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,23 @@
Workouts Plus is a workout tracker for iPhone and Apple Watch. Plan your routine on the phone, then leave it in your pocket and run the whole session from your wrist.
PLAN YOUR SPLITS
- Organize exercises into reusable routines with their own colors and icons
- Start from a built-in catalog of bodyweight and machine exercises, or add your own
- Set default sets, reps and weight — or a timed duration — for each exercise
RUN IT FROM YOUR WRIST
- Start a workout on iPhone and your Apple Watch opens straight into it
- Each exercise runs as a HIIT-style cycle: a count-up work phase, then a rest that counts down with a haptic countdown before the next set slides in
- Tap One More for a bonus set, or Done to finish and move on
- Set your rest length once on iPhone and it syncs to the watch
PROGRESS THAT KEEPS UP
- Finish a set on the watch and the iPhone advances in real time — no need to touch your phone
- Per-exercise weight-progression charts show how you're trending across past sessions
YOUR DATA, IN YOUR ICLOUD
- Everything is stored as plain files in your own iCloud Drive — browsable in the Files app and synced across your devices through Apple, never through a server of mine
- No accounts, no ads, no tracking
- iCloud is required
Built for lifters who plan on the phone and train from the wrist.
@@ -0,0 +1 @@
gym,strength,exercise,routine,reps,sets,lifting,training,fitness,watch,tracker,rest,hiit,interval
@@ -0,0 +1 @@
https://indie.rzen.dev/apps/workouts
@@ -0,0 +1 @@
Plan your splits on iPhone, then run the whole session from your Apple Watch — count-up work, count-down rests, and set progress that syncs back live.
@@ -0,0 +1 @@
https://indie.rzen.dev/apps/workouts/support
+8
View File
@@ -0,0 +1,8 @@
{
"contactFirstName": "Rouslan",
"contactLastName": "Zenetl",
"contactPhone": "+16465840598",
"contactEmail": "rzenetl@gmail.com",
"demoAccountRequired": false,
"notes": "Workouts Plus stores its data as JSON files in the reviewer's own iCloud Drive, so please sign into iCloud with iCloud Drive enabled before testing — without iCloud the app shows a blocking \"iCloud Required\" screen. To try it: open the iPhone app, tap \"Add Starter Splits\" in Settings (or create a split), then start a workout from a split. Starting a workout launches the paired Apple Watch app into the session via HealthKit (allow the Health prompt), so please review on a paired Apple Watch. On the watch, tap an exercise and swipe through the work/rest pages; completing a set syncs back to the iPhone in real time. The HealthKit permission is used only to launch and run the on-watch workout session — the app does not read your health data."
}
+104
View File
@@ -0,0 +1,104 @@
#if DEBUG
import Foundation
import SwiftData
/// DEBUG-only sample data + launch-arg plumbing for App Store screenshot capture.
/// Activated by launching with `--screenshot`; `--screen <name>` picks which screen
/// the app renders (see each target's screenshot root). Never compiled into release.
enum ScreenshotSeed {
static var isActive: Bool { CommandLine.arguments.contains("--screenshot") }
static func screen(default def: String) -> String {
let args = CommandLine.arguments
if let i = args.firstIndex(of: "--screen"), i + 1 < args.count { return args[i + 1] }
return def
}
/// Insert a believable in-progress workout, a few finished sessions (so charts have
/// a trend), and the starter splits. Returns the in-progress workout to display.
@MainActor
@discardableResult
static func populate(_ context: ModelContext) -> Workout? {
// Idempotent: clear any prior seed so repeated launches don't stack duplicates
// (cascade deletes take the child exercises/logs with them).
try? context.delete(model: Workout.self)
try? context.delete(model: Split.self)
let cal = Calendar(identifier: .gregorian)
let today = Date()
func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today }
// ---- Splits (with exercises) ------------------------------------------
let splits: [(String, String, String, [(String, Int, Int, Int)])] = [
("Upper Body", "purple", "dumbbell.fill", [
("Bench Press", 4, 10, 135), ("Overhead Press", 4, 10, 75),
("Lat Pulldown", 4, 12, 120), ("Bicep Curl", 3, 12, 30),
]),
("Lower Body", "blue", "figure.strengthtraining.traditional", [
("Back Squat", 5, 5, 185), ("Romanian Deadlift", 4, 8, 155),
("Leg Press", 4, 12, 270), ("Calf Raise", 4, 15, 90),
]),
("Core", "orange", "figure.core.training", [
("Plank", 3, 0, 0), ("Hanging Leg Raise", 3, 12, 0),
("Cable Crunch", 3, 15, 50),
]),
]
for (sIndex, s) in splits.enumerated() {
let split = Split(id: ULID.make(), name: s.0, color: s.1, systemImage: s.2,
order: sIndex, createdAt: today, updatedAt: today, jsonRelativePath: "")
context.insert(split)
for (eIndex, e) in s.3.enumerated() {
let isDuration = e.0 == "Plank"
let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2,
weight: e.3, loadType: (isDuration ? LoadType.duration : .weight).rawValue,
durationTotalSeconds: isDuration ? 45 : 0, weightLastUpdated: today,
weightReminderTimeIntervalWeeks: 2)
ex.split = split
context.insert(ex)
}
}
let upper = splits[0]
// ---- Past finished sessions (Bench Press trend for the chart) ----------
let benchTrend = [115, 120, 125, 130]
for (i, w) in benchTrend.enumerated() {
let date = daysAgo((benchTrend.count - i) * 4)
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date,
end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue,
createdAt: date, updatedAt: date, jsonRelativePath: "")
context.insert(workout)
for (eIndex, e) in upper.3.enumerated() {
let weight = e.0 == "Bench Press" ? w : e.3
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1,
reps: e.2, weight: weight, loadType: LoadType.weight.rawValue,
durationTotalSeconds: 0, currentStateIndex: e.1, completed: true,
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date)
log.workout = workout
context.insert(log)
}
}
// ---- The current in-progress session -----------------------------------
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: today,
end: nil, statusRaw: WorkoutStatus.inProgress.rawValue,
createdAt: today, updatedAt: today, jsonRelativePath: "")
context.insert(workout)
// Bench Press done, Overhead Press partway, the rest to go.
let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)]
for (eIndex, e) in upper.3.enumerated() {
let (idx, status) = progress[eIndex]
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2,
weight: e.0 == "Bench Press" ? 135 : e.3, loadType: LoadType.weight.rawValue,
durationTotalSeconds: 0, currentStateIndex: idx, completed: status == .completed,
statusRaw: status.rawValue, notes: nil, date: today)
log.workout = workout
context.insert(log)
}
try? context.save()
return workout
}
}
#endif
@@ -35,17 +35,22 @@ struct ExerciseProgressView: View {
@State private var showingCancelConfirm = false
@State private var didRestorePage = false
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void) {
/// Forces the starting page (used only by the DEBUG screenshot host to land on a
/// rest page). Always nil in normal use.
private let debugInitialPage: Int?
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, debugInitialPage: Int? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.debugInitialPage = debugInitialPage
let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets)
// Resume on the first unfinished set's work page (clamped to the last set).
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1)
_currentPage = State(initialValue: completed * 2)
_currentPage = State(initialValue: debugInitialPage ?? (completed * 2))
}
private var log: WorkoutLogDocument? {
@@ -96,13 +101,16 @@ struct ExerciseProgressView: View {
}
.onAppear {
// Jump to the first unfinished set. A paged TabView can settle on page 0 on
// first layout, so re-assert once more after this run loop.
// first layout, so re-assert once more after this run loop. (The screenshot
// host pins an explicit page, so skip the resume jump there.)
guard !didRestorePage else { return }
didRestorePage = true
if debugInitialPage == nil {
jumpToResumePage()
Task { @MainActor in jumpToResumePage() }
}
}
}
/// Move to the resume page without animation, only if we're not already there
/// (so a re-assert after a TabView snap-to-0 is a no-op in the common case).
@@ -15,6 +15,9 @@ final class WatchAppServices {
let container = WorkoutsModelContainer.make()
self.container = container
self.bridge = WatchConnectivityBridge(container: container)
#if DEBUG
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
#endif
}
func activate() {
@@ -0,0 +1,60 @@
#if DEBUG
import SwiftUI
import SwiftData
/// DEBUG-only root for App Store screenshot capture on the watch. Renders one
/// fully-formed screen (chosen by `--screen`) against the seeded cache so captures are
/// deterministic. Never compiled into release.
struct WatchScreenshotRoot: View {
let services: WatchAppServices
private var activeWorkout: Workout? {
let context = services.container.mainContext
var descriptor = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
descriptor.fetchLimit = 25
let workouts = (try? context.fetch(descriptor)) ?? []
return workouts.first { $0.status == .inProgress } ?? workouts.first
}
var body: some View {
content
.environment(services.bridge)
.modelContainer(services.container)
}
@ViewBuilder
private var content: some View {
if let workout = activeWorkout {
switch ScreenshotSeed.screen(default: "list") {
case "work":
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: nil)
case "rest":
ProgressHost(workout: workout, exerciseName: "Lat Pulldown", page: 1)
default:
NavigationStack { WorkoutLogListView(workout: workout) }
}
} else {
Color.black
}
}
}
/// Hosts the progress view with a working-copy binding (and an optional pinned page so
/// we can capture a rest phase, which the normal resume logic never lands on).
private struct ProgressHost: View {
@State private var doc: WorkoutDocument
private let logID: String
private let page: Int?
init(workout: Workout, exerciseName: String, page: Int?) {
let document = WorkoutDocument(from: workout)
_doc = State(initialValue: document)
logID = document.logs.first { $0.exerciseName == exerciseName }?.id ?? document.logs.first?.id ?? ""
self.page = page
}
var body: some View {
ExerciseProgressView(doc: $doc, logID: logID, onChange: {}, debugInitialPage: page)
}
}
#endif
+13 -1
View File
@@ -15,11 +15,23 @@ struct WorkoutsWatchApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if ScreenshotSeed.isActive {
WatchScreenshotRoot(services: services)
} else {
root
}
#else
root
#endif
}
}
private var root: some View {
ContentView()
.environment(services.bridge)
.environment(appDelegate.sessionManager)
.modelContainer(services.container)
.task { services.activate() }
}
}
}
+3
View File
@@ -20,6 +20,9 @@ final class AppServices {
self.container = container
self.syncEngine = SyncEngine(container: container)
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine)
#if DEBUG
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
#endif
}
/// Launch step: resolve iCloud and reconcile the cache. Idempotent repeated
@@ -0,0 +1,43 @@
#if DEBUG
import SwiftUI
import SwiftData
/// DEBUG-only root used for App Store screenshot capture. Bypasses the iCloud gate and
/// renders one fully-formed screen (chosen by `--screen`) against the seeded cache, so
/// captures are deterministic with no UI automation. Never compiled into release.
struct ScreenshotRootView: View {
let services: AppServices
private var activeWorkout: Workout? {
let context = services.container.mainContext
var descriptor = FetchDescriptor<Workout>(sortBy: [SortDescriptor(\.start, order: .reverse)])
descriptor.fetchLimit = 25
let workouts = (try? context.fetch(descriptor)) ?? []
return workouts.first { $0.status == .inProgress } ?? workouts.first
}
var body: some View {
content
.environment(services)
.environment(services.syncEngine)
.modelContainer(services.container)
}
@ViewBuilder
private var content: some View {
if let workout = activeWorkout {
switch ScreenshotSeed.screen(default: "workouts") {
case "exercise":
let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Bench Press" }?.id
NavigationStack { ExerciseView(workout: workout, logID: logID ?? "") }
case "settings":
SettingsView()
default:
NavigationStack { WorkoutLogListView(workout: workout) }
}
} else {
Color(.systemBackground)
}
}
}
#endif
@@ -35,7 +35,11 @@ struct ExerciseView: View {
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout
self.logID = logID
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
let initialDoc = seedDoc ?? WorkoutDocument(from: workout)
_doc = State(initialValue: initialDoc)
// Seed progress from the log so the set grid is correct on the first frame
// (onAppear also refreshes it, but that lags the initial render).
_progress = State(initialValue: initialDoc.logs.first { $0.id == logID }?.currentStateIndex ?? 0)
}
/// The log being edited within the working doc.
+13 -1
View File
@@ -14,11 +14,23 @@ struct WorkoutsApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if ScreenshotSeed.isActive {
ScreenshotRootView(services: services)
} else {
root
}
#else
root
#endif
}
}
private var root: some View {
RootGateView()
.environment(services)
.environment(services.syncEngine)
.modelContainer(services.container)
.task { await services.bootstrap() }
}
}
}