diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d34d3..4a92244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/Scripts/metadata/app/categories.json b/Scripts/metadata/app/categories.json new file mode 100644 index 0000000..279c457 --- /dev/null +++ b/Scripts/metadata/app/categories.json @@ -0,0 +1,4 @@ +{ + "primary": "HEALTH_AND_FITNESS", + "secondary": null +} diff --git a/Scripts/metadata/app/en-US/name.txt b/Scripts/metadata/app/en-US/name.txt new file mode 100644 index 0000000..49d455f --- /dev/null +++ b/Scripts/metadata/app/en-US/name.txt @@ -0,0 +1 @@ +Workouts Plus \ No newline at end of file diff --git a/Scripts/metadata/app/en-US/privacy_url.txt b/Scripts/metadata/app/en-US/privacy_url.txt new file mode 100644 index 0000000..71ed368 --- /dev/null +++ b/Scripts/metadata/app/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://indie.rzen.dev/apps/workouts/privacy \ No newline at end of file diff --git a/Scripts/metadata/app/en-US/subtitle.txt b/Scripts/metadata/app/en-US/subtitle.txt new file mode 100644 index 0000000..83ac68b --- /dev/null +++ b/Scripts/metadata/app/en-US/subtitle.txt @@ -0,0 +1 @@ +Splits you run from your wrist \ No newline at end of file diff --git a/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/01-workout.png b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/01-workout.png new file mode 100644 index 0000000..699d54c Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/01-workout.png differ diff --git a/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/02-exercise.png b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/02-exercise.png new file mode 100644 index 0000000..c7528dc Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/02-exercise.png differ diff --git a/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/03-settings.png b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/03-settings.png new file mode 100644 index 0000000..c2c9e9f Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_IPHONE_67/03-settings.png differ diff --git a/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/01-exercises.png b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/01-exercises.png new file mode 100644 index 0000000..2cf0ca3 Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/01-exercises.png differ diff --git a/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/02-work.png b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/02-work.png new file mode 100644 index 0000000..f60efaf Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/02-work.png differ diff --git a/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/03-rest.png b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/03-rest.png new file mode 100644 index 0000000..80dda37 Binary files /dev/null and b/Scripts/metadata/screenshots/en-US/APP_WATCH_SERIES_10/03-rest.png differ diff --git a/Scripts/metadata/version/en-US/description.txt b/Scripts/metadata/version/en-US/description.txt new file mode 100644 index 0000000..cf5d3ec --- /dev/null +++ b/Scripts/metadata/version/en-US/description.txt @@ -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. diff --git a/Scripts/metadata/version/en-US/keywords.txt b/Scripts/metadata/version/en-US/keywords.txt new file mode 100644 index 0000000..5ecf84b --- /dev/null +++ b/Scripts/metadata/version/en-US/keywords.txt @@ -0,0 +1 @@ +gym,strength,exercise,routine,reps,sets,lifting,training,fitness,watch,tracker,rest,hiit,interval \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/marketing_url.txt b/Scripts/metadata/version/en-US/marketing_url.txt new file mode 100644 index 0000000..592e772 --- /dev/null +++ b/Scripts/metadata/version/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://indie.rzen.dev/apps/workouts \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/promotional_text.txt b/Scripts/metadata/version/en-US/promotional_text.txt new file mode 100644 index 0000000..fff0f51 --- /dev/null +++ b/Scripts/metadata/version/en-US/promotional_text.txt @@ -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. \ No newline at end of file diff --git a/Scripts/metadata/version/en-US/support_url.txt b/Scripts/metadata/version/en-US/support_url.txt new file mode 100644 index 0000000..083c77c --- /dev/null +++ b/Scripts/metadata/version/en-US/support_url.txt @@ -0,0 +1 @@ +https://indie.rzen.dev/apps/workouts/support \ No newline at end of file diff --git a/Scripts/metadata/version/review.json b/Scripts/metadata/version/review.json new file mode 100644 index 0000000..6043313 --- /dev/null +++ b/Scripts/metadata/version/review.json @@ -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." +} diff --git a/Shared/Screenshots/ScreenshotSeed.swift b/Shared/Screenshots/ScreenshotSeed.swift new file mode 100644 index 0000000..2a4e98d --- /dev/null +++ b/Shared/Screenshots/ScreenshotSeed.swift @@ -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 ` 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 diff --git a/Workouts Watch App/Views/ExerciseProgressView.swift b/Workouts Watch App/Views/ExerciseProgressView.swift index 6cb8cdc..93bd608 100644 --- a/Workouts Watch App/Views/ExerciseProgressView.swift +++ b/Workouts Watch App/Views/ExerciseProgressView.swift @@ -35,17 +35,22 @@ struct ExerciseProgressView: View { @State private var showingCancelConfirm = false @State private var didRestorePage = false - init(doc: Binding, 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, 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,11 +101,14 @@ 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 - jumpToResumePage() - Task { @MainActor in jumpToResumePage() } + if debugInitialPage == nil { + jumpToResumePage() + Task { @MainActor in jumpToResumePage() } + } } } diff --git a/Workouts Watch App/WatchAppServices.swift b/Workouts Watch App/WatchAppServices.swift index 7cf8628..d15ae87 100644 --- a/Workouts Watch App/WatchAppServices.swift +++ b/Workouts Watch App/WatchAppServices.swift @@ -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() { diff --git a/Workouts Watch App/WatchScreenshotRoot.swift b/Workouts Watch App/WatchScreenshotRoot.swift new file mode 100644 index 0000000..8b35472 --- /dev/null +++ b/Workouts Watch App/WatchScreenshotRoot.swift @@ -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(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 diff --git a/Workouts Watch App/WorkoutsApp.swift b/Workouts Watch App/WorkoutsApp.swift index 67dee55..f00e140 100644 --- a/Workouts Watch App/WorkoutsApp.swift +++ b/Workouts Watch App/WorkoutsApp.swift @@ -15,11 +15,23 @@ struct WorkoutsWatchApp: App { var body: some Scene { WindowGroup { - ContentView() - .environment(services.bridge) - .environment(appDelegate.sessionManager) - .modelContainer(services.container) - .task { services.activate() } + #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() } + } } diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 744a068..9daff94 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -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 diff --git a/Workouts/Screenshots/ScreenshotRootView.swift b/Workouts/Screenshots/ScreenshotRootView.swift new file mode 100644 index 0000000..91510aa --- /dev/null +++ b/Workouts/Screenshots/ScreenshotRootView.swift @@ -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(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 diff --git a/Workouts/Views/WorkoutLogs/ExerciseView.swift b/Workouts/Views/WorkoutLogs/ExerciseView.swift index a74998c..50e9264 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseView.swift @@ -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. diff --git a/Workouts/WorkoutsApp.swift b/Workouts/WorkoutsApp.swift index 08dfd74..f8cce34 100644 --- a/Workouts/WorkoutsApp.swift +++ b/Workouts/WorkoutsApp.swift @@ -14,11 +14,23 @@ struct WorkoutsApp: App { var body: some Scene { WindowGroup { - RootGateView() - .environment(services) - .environment(services.syncEngine) - .modelContainer(services.container) - .task { await services.bootstrap() } + #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() } + } }