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 ## 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 - 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), 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 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 showingCancelConfirm = false
@State private var didRestorePage = 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._doc = doc
self.logID = logID self.logID = logID
self.onChange = onChange self.onChange = onChange
self.debugInitialPage = debugInitialPage
let log = doc.wrappedValue.logs.first { $0.id == logID } let log = doc.wrappedValue.logs.first { $0.id == logID }
let sets = max(1, log?.sets ?? 1) let sets = max(1, log?.sets ?? 1)
_setCount = State(initialValue: sets) _setCount = State(initialValue: sets)
// Resume on the first unfinished set's work page (clamped to the last set). // Resume on the first unfinished set's work page (clamped to the last set).
let completed = min(max(0, log?.currentStateIndex ?? 0), sets - 1) 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? { private var log: WorkoutLogDocument? {
@@ -96,13 +101,16 @@ struct ExerciseProgressView: View {
} }
.onAppear { .onAppear {
// Jump to the first unfinished set. A paged TabView can settle on page 0 on // 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 } guard !didRestorePage else { return }
didRestorePage = true didRestorePage = true
if debugInitialPage == nil {
jumpToResumePage() jumpToResumePage()
Task { @MainActor in jumpToResumePage() } Task { @MainActor in jumpToResumePage() }
} }
} }
}
/// Move to the resume page without animation, only if we're not already there /// 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). /// (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() let container = WorkoutsModelContainer.make()
self.container = container self.container = container
self.bridge = WatchConnectivityBridge(container: container) self.bridge = WatchConnectivityBridge(container: container)
#if DEBUG
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
#endif
} }
func activate() { 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,6 +15,19 @@ struct WorkoutsWatchApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
#if DEBUG
if ScreenshotSeed.isActive {
WatchScreenshotRoot(services: services)
} else {
root
}
#else
root
#endif
}
}
private var root: some View {
ContentView() ContentView()
.environment(services.bridge) .environment(services.bridge)
.environment(appDelegate.sessionManager) .environment(appDelegate.sessionManager)
@@ -22,4 +35,3 @@ struct WorkoutsWatchApp: App {
.task { services.activate() } .task { services.activate() }
} }
} }
}
+3
View File
@@ -20,6 +20,9 @@ final class AppServices {
self.container = container self.container = container
self.syncEngine = SyncEngine(container: container) self.syncEngine = SyncEngine(container: container)
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine) 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 /// 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) { init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
self.workout = workout self.workout = workout
self.logID = logID 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. /// The log being edited within the working doc.
+13 -1
View File
@@ -14,6 +14,19 @@ struct WorkoutsApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
#if DEBUG
if ScreenshotSeed.isActive {
ScreenshotRootView(services: services)
} else {
root
}
#else
root
#endif
}
}
private var root: some View {
RootGateView() RootGateView()
.environment(services) .environment(services)
.environment(services.syncEngine) .environment(services.syncEngine)
@@ -21,4 +34,3 @@ struct WorkoutsApp: App {
.task { await services.bootstrap() } .task { await services.bootstrap() }
} }
} }
}