From 5783344bb0fb4654f912e9c437d40f774db36971 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 16:49:54 +0000 Subject: [PATCH 01/11] Add CLAUDE.md with comprehensive codebase documentation Documents project structure, architecture patterns (SOLID), data models, development commands, testing conventions, CI/CD pipeline, theme system, and key conventions for AI assistants working on the codebase. https://claude.ai/code/session_01NytaxeQoLadhKcESsKqjmT --- CLAUDE.md | 291 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a8fa8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,291 @@ +# CLAUDE.md — RepForge Workout Logger + +This file provides guidance for AI assistants working on the RepForge codebase. + +--- + +## Project Overview + +**RepForge** is a Flutter-based cross-platform workout logging mobile application targeting Android. It provides: +- Workout session tracking with sets, reps, and weights +- Analytics and progress visualization (FL Chart) +- AI-powered set recommendations (linear regression) +- Goal/target tracking with ML-estimated completion dates +- Customizable exercise library +- Reusable workout routines + +**Package:** `com.devasy.repforge` +**Version:** `1.0.6+7` +**Flutter SDK:** `^3.9.2` + +--- + +## Repository Structure + +``` +Workout-logger/ +├── workout-logger/ # Main Flutter application (work here) +│ ├── lib/ +│ │ ├── main.dart # App entry point, DI composition root +│ │ ├── theme/ +│ │ │ └── app_theme.dart # Dark theme, spacing, colors, muscle group colors +│ │ ├── models/ +│ │ │ └── models.dart # ALL data models (~396 lines) +│ │ ├── services/ +│ │ │ ├── interfaces/ # IStorageService, IMLService abstractions +│ │ │ ├── managers/ # SRP-focused feature managers +│ │ │ ├── strategies/ # OCP target calculation strategies +│ │ │ ├── storage_service.dart # Hive persistence +│ │ │ ├── ml_service.dart # Linear regression ML +│ │ │ └── workout_provider.dart # Main ChangeNotifier state +│ │ ├── screens/ # 7 UI screens +│ │ └── data/ +│ │ └── exercise_database.dart # 50+ built-in exercises +│ ├── test/ # flutter_test + Mockito tests +│ │ └── test_utils/ # MockStorageService, MockMLService +│ ├── pubspec.yaml +│ └── analysis_options.yaml +├── docs/ +│ ├── RELEASE_WORKFLOW.md +│ └── design/ # Feature design proposals (9 docs) +├── scripts/ +│ └── bump_version.dart # Patch version bump script +├── .github/workflows/ +│ └── release.yml # Auto-release CI/CD pipeline +└── SOLID_ANALYSIS_REPORT.md # Architecture refactoring rationale +``` + +--- + +## Development Commands + +All commands run from inside `workout-logger/`: + +```bash +# Install dependencies +flutter pub get + +# Run the app (requires connected device/emulator) +flutter run + +# Run all tests +flutter test + +# Run a specific test file +flutter test test/workout_provider_test.dart + +# Check lint / static analysis +flutter analyze + +# Build release APK +flutter build apk --release + +# Generate Mockito mocks (after modifying interfaces) +dart run build_runner build --delete-conflicting-outputs + +# Bump patch version (used by CI) +dart ../scripts/bump_version.dart patch +``` + +--- + +## Architecture & Key Patterns + +### Dependency Injection (Composition Root) +All services are wired in `main.dart` via `AppInitializer`. The constructor injection pattern means: +- `WorkoutProvider` receives `IStorageService` and `IMLService` +- Managers receive only the dependencies they need +- Tests swap real implementations for mocks + +### SOLID Principles +This codebase was explicitly refactored around SOLID — see `SOLID_ANALYSIS_REPORT.md`. + +| Principle | Implementation | +|-----------|---------------| +| **SRP** | 6 managers (`ActiveWorkoutManager`, `HistoryManager`, `RoutineManager`, `ExerciseManager`, `TargetManager`, `AnalyticsManager`) each own one concern | +| **OCP** | `TargetCalculatorStrategy` + `TargetCalculatorFactory` for extensible target types | +| **LSP** | `MockStorageService`/`MockMLService` are fully substitutable for real impls | +| **ISP** | Screens depend only on their needed manager, not a monolithic interface | +| **DIP** | All dependencies flow through `IStorageService` and `IMLService` interfaces | + +### State Management +- **Provider** (`ChangeNotifier`) pattern throughout +- `WorkoutProvider` is the top-level orchestrator +- Individual managers call `notifyListeners()` when their slice of state changes +- Use `Consumer` or `context.watch()` in widgets + +### Data Persistence +- **Hive** (key-value, NoSQL) — no SQL, no cloud required +- 6 boxes: `workout_sessions`, `routines`, `targets`, `muscle_groups`, `custom_exercises`, `settings` +- All models serialize to/from JSON for Hive storage +- Export/import available for user data portability + +### ML Service +- Linear regression (least-squares) on session number vs. volume +- R² coefficient tracks model quality +- Set recommendations use two strategies: + 1. Add reps (up to 12 max) + 2. Increase weight by 2.5–5kg +- Reps clamped to 6–15 range + +--- + +## Data Models (lib/models/models.dart) + +Key models and their most-used getters: + +| Model | Key Fields | Useful Getters | +|-------|-----------|----------------| +| `Exercise` | `id`, `name`, `muscleActivations`, `category`, `isCustom` | `primaryMuscle` | +| `WorkoutSet` | `weight`, `reps`, `isDropset`, `drops` | `volume` (weight × reps) | +| `ExerciseLog` | `exerciseId`, `sets`, `notes` | `totalVolume` | +| `WorkoutSession` | `id`, `date`, `routineId`, `exercises`, `duration` | `totalVolume` | +| `Target` | `exerciseId`, `targetType`, `targetValue`, `currentValue` | `progressPercentage` | +| `GrowthModel` | `slope`, `intercept`, `r²`, `lastTrained` | `predict(n)` | +| `SetRecommendation` | `weight`, `reps`, `confidence`, `reasoning` | — | +| `MuscleGroup` | `id`, `name`, `growthRate` | — | + +All models implement `copyWith()` for immutable updates. + +--- + +## Theme & Design System (lib/theme/app_theme.dart) + +**Color tokens:** +- `AppColors.primary` — `#6C5CE7` (purple) +- `AppColors.secondary` — `#00D9FF` (cyan) +- `AppColors.accent` — `#FF6B6B` (red/salmon) +- `AppColors.background` — `#0D1117` +- `AppColors.surface` — `#161B22` +- `AppColors.card` — `#21262D` + +**Muscle group colors:** 18 distinct colors accessible via `AppColors.muscleGroupColors[muscleId]`. + +**Spacing scale:** `AppSpacing.xs` (4) → `AppSpacing.xxl` (48) +**Border radius:** `AppRadius.sm` (8) → `AppRadius.full` (999) + +Always use the design system tokens rather than hardcoded values. + +--- + +## Screens (lib/screens/) + +| Screen | Route/Usage | Key Dependencies | +|--------|-------------|-----------------| +| `HomeScreen` | Root, bottom nav | All managers | +| `WorkoutFlowScreen` | Active workout | `ActiveWorkoutManager` | +| `HistoryScreen` | Past sessions | `HistoryManager` | +| `AnalyticsScreen` | Charts/progress | `AnalyticsManager`, `TargetManager` | +| `RoutinesScreen` | Routine CRUD | `RoutineManager`, `ExerciseManager` | +| `ExerciseLibraryScreen` | Browse exercises | `ExerciseManager` | +| `AddCustomExerciseScreen` | Create exercise | `ExerciseManager` | + +Navigation uses `IndexedStack` — switching tabs preserves scroll position. + +--- + +## Testing Conventions + +**Location:** `workout-logger/test/` + +**Pattern:** Arrange–Act–Assert (AAA) with Mockito mocks. + +```dart +// Always use mock services from test_utils/ +final mockStorage = MockStorageService(); +final mockML = MockMLService(); + +// Wire via WorkoutProvider constructor +final provider = WorkoutProvider(mockStorage, mockML); +``` + +**Mock setup:** +- `MockStorageService` — in-memory, fulfills `IStorageService` +- `MockMLService` — stub responses, fulfills `IMLService` + +After modifying service interfaces, regenerate mocks: +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +**Run tests before committing** — the CI does not run tests, only builds the APK. + +--- + +## CI/CD Pipeline (.github/workflows/release.yml) + +Triggers automatically on push to `main`: +1. Runs `dart scripts/bump_version.dart patch` (increments patch version) +2. Commits version bump and creates a git tag (`v{version}`) +3. Builds release APK with Flutter 3.38.7 +4. Creates a GitHub Release with the APK attached + +**Do not** manually edit `pubspec.yaml` version before merging to `main` — the CI handles it. For a minor/major bump, edit `pubspec.yaml` manually before the merge. + +--- + +## Key Conventions + +### Dart / Flutter Style +- Follow `flutter_lints` rules (enforced by `analysis_options.yaml`) +- Use `const` constructors wherever possible +- Prefer `final` for local variables +- Use named parameters for clarity on functions with 3+ args +- All model mutations go through `copyWith()` — never mutate state directly + +### Adding a New Feature +1. **Model:** Add or extend in `lib/models/models.dart` +2. **Interface:** If new persistence/ML methods needed, add to `lib/services/interfaces/` +3. **Storage:** Implement in `lib/services/storage_service.dart` +4. **Manager:** Add a new manager in `lib/services/managers/` or extend an existing one +5. **Provider:** Wire the manager into `WorkoutProvider` (or inject directly) +6. **Screen:** Create or update a screen in `lib/screens/` +7. **Tests:** Add tests in `test/` using mock services + +### Adding a New Exercise +Add to `lib/data/exercise_database.dart` following the existing pattern: +```dart +Exercise( + id: 'unique_id', + name: 'Exercise Name', + category: 'compound', // or 'isolation' + muscleActivations: [ + MuscleActivation(muscleGroupId: 'chest', activationPercentage: 70), + MuscleActivation(muscleGroupId: 'triceps', activationPercentage: 30), + ], +), +``` + +### Adding a New Target Type +Implement `TargetCalculatorStrategy` and register in `TargetCalculatorFactory`: +```dart +class MyTargetCalculator implements TargetCalculatorStrategy { ... } +TargetCalculatorFactory.registerCalculator('my_type', MyTargetCalculator()); +``` + +--- + +## Known Constraints & Gotchas + +- **Hive boxes must be opened** before use — `StorageService.init()` opens all boxes at startup in `main.dart`. Do not open boxes elsewhere. +- **Exercise IDs are UUIDs** — always use `const Uuid().v4()` for new exercises, never sequential integers. +- **Muscle activation percentages** do not need to sum to 100 — they are relative activation levels, not strict splits. +- **ML model needs ≥ 2 data points** — `GrowthModel` returns null/defaults with fewer than 2 workout sessions for an exercise. +- **Custom exercises** are stored separately from built-in exercises; `ExerciseManager.getAllExercises()` merges both lists. +- **Bottom nav uses IndexedStack** — all 4 tab screens are always mounted; avoid expensive init work in `build()`. + +--- + +## Design Documents + +Feature proposals live in `docs/design/`. Before implementing a major feature, check if a design doc exists: + +- `add_custom_exercise.md` ✅ (implemented) +- `exercise_supersets.md` +- `muscle_recovery_tracker.md` +- `personal_records.md` +- `rest_timer_customization.md` +- `social_features.md` +- `wearables_integration.md` +- `workout_scheduling.md` +- `workout_sharing.md` From d3f86fd7088b8cd0301b212676d067a5978d5fcd Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:40:38 +0530 Subject: [PATCH 02/11] Update CLAUDE.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a8fa8f..96d4b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,8 +112,8 @@ This codebase was explicitly refactored around SOLID — see `SOLID_ANALYSIS_REP - **Provider** (`ChangeNotifier`) pattern throughout - `WorkoutProvider` is the top-level orchestrator - Individual managers call `notifyListeners()` when their slice of state changes -- Use `Consumer` or `context.watch()` in widgets - +- Prefer watching the smallest scoped manager/provider needed by a widget. +- Avoid broad `context.watch()` in leaf widgets; use selector/manager-specific access to reduce coupling and rebuilds. ### Data Persistence - **Hive** (key-value, NoSQL) — no SQL, no cloud required - 6 boxes: `workout_sessions`, `routines`, `targets`, `muscle_groups`, `custom_exercises`, `settings` From 3558af0bba9f62e26ecb95e5c75647404caf8c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 12:01:43 +0000 Subject: [PATCH 03/11] feat: add Training Program Planner with import/design UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete multi-week training program feature inspired by the 12-week periodisation reference plan (phases, deload weeks, tempo notation, rep ranges, rest times, superset grouping). Models (models.dart): - ProgramExerciseSlot — per-exercise parameters (sets, rep range, rest seconds, tempo, weight %, notes, superset group) - ProgramDay — named training day with ordered exercise slots - ProgramWeek — week with deload flag, intensity factor, set reduction - TrainingPhase — named phase spanning a range of weeks - TrainingProgram — top-level entity; full JSON serialization Storage: - New 'training_programs' Hive box in StorageService - saveTrainingProgram / getAllTrainingPrograms / getTrainingProgram / deleteTrainingProgram added to IStorageService interface and implemented in StorageService + MockStorageService ProgramManager (SRP manager): - loadPrograms, saveProgram, createProgram, deleteProgram - importFromJson (assigns new UUID, marks isImported=true) - exportToJson (pretty-printed) WorkoutProvider: - Exposes programManager; loadPrograms() called during init UI (screens/programs/): - ProgramsScreen — list with mini phase timeline, import FAB - ProgramDetailScreen — phase timeline bar, expandable week list with deload badges, per-day exercise breakdown (sets×reps, rest, tempo chip, weight %, superset bracket, notes) - ProgramDesignerScreen — 3-step wizard: metadata/phases → week structure (deload toggle, intensity/set-reduction steppers, add days) → exercise slots per day with full parameter editing RoutinesScreen: - Added Programs tab (TabBar + TabBarView) alongside existing Routines tab; no existing functionality changed https://claude.ai/code/session_01E15CGTZZDU8rz7PX1nYXA3 --- workout-logger/lib/models/models.dart | 354 +++++++ .../programs/program_designer_screen.dart | 926 ++++++++++++++++++ .../programs/program_detail_screen.dart | 766 +++++++++++++++ .../lib/screens/programs/programs_screen.dart | 407 ++++++++ .../lib/screens/routines_screen.dart | 33 +- .../interfaces/storage_service_interface.dart | 7 + .../lib/services/managers/managers.dart | 1 + .../services/managers/program_manager.dart | 106 ++ .../lib/services/storage_service.dart | 37 + .../lib/services/workout_provider.dart | 9 +- .../test/test_utils/mock_storage_service.dart | 29 + 11 files changed, 2672 insertions(+), 3 deletions(-) create mode 100644 workout-logger/lib/screens/programs/program_designer_screen.dart create mode 100644 workout-logger/lib/screens/programs/program_detail_screen.dart create mode 100644 workout-logger/lib/screens/programs/programs_screen.dart create mode 100644 workout-logger/lib/services/managers/program_manager.dart diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index c1dfe1a..e6fb57e 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -393,3 +393,357 @@ class GrowthModel { return slope * sessionNumber + intercept; } } + +// ==================== Training Program ==================== + +/// One exercise slot inside a program day. +/// +/// Holds all programming parameters: sets, rep range, rest, tempo, weight%, notes, +/// and an optional superset group ID to visually link paired/grouped exercises. +class ProgramExerciseSlot { + final String exerciseId; + final int sets; + final int minReps; + final int maxReps; + final int restSeconds; + final String? tempo; // e.g. "3-1-1" (eccentric-pause-concentric) + final double? weightPercentage; // % of working weight / 1RM hint + final String? notes; + final String? supersetGroupId; // non-null → belongs to a superset + + ProgramExerciseSlot({ + required this.exerciseId, + required this.sets, + required this.minReps, + required this.maxReps, + required this.restSeconds, + this.tempo, + this.weightPercentage, + this.notes, + this.supersetGroupId, + }); + + Map toJson() => { + 'exerciseId': exerciseId, + 'sets': sets, + 'minReps': minReps, + 'maxReps': maxReps, + 'restSeconds': restSeconds, + 'tempo': tempo, + 'weightPercentage': weightPercentage, + 'notes': notes, + 'supersetGroupId': supersetGroupId, + }; + + factory ProgramExerciseSlot.fromJson(Map json) => + ProgramExerciseSlot( + exerciseId: json['exerciseId'] as String, + sets: json['sets'] as int, + minReps: json['minReps'] as int, + maxReps: json['maxReps'] as int, + restSeconds: json['restSeconds'] as int, + tempo: json['tempo'] as String?, + weightPercentage: (json['weightPercentage'] as num?)?.toDouble(), + notes: json['notes'] as String?, + supersetGroupId: json['supersetGroupId'] as String?, + ); + + ProgramExerciseSlot copyWith({ + Object? exerciseId = _sentinel, + Object? sets = _sentinel, + Object? minReps = _sentinel, + Object? maxReps = _sentinel, + Object? restSeconds = _sentinel, + Object? tempo = _sentinel, + Object? weightPercentage = _sentinel, + Object? notes = _sentinel, + Object? supersetGroupId = _sentinel, + }) => ProgramExerciseSlot( + exerciseId: + exerciseId == _sentinel ? this.exerciseId : exerciseId as String, + sets: sets == _sentinel ? this.sets : sets as int, + minReps: minReps == _sentinel ? this.minReps : minReps as int, + maxReps: maxReps == _sentinel ? this.maxReps : maxReps as int, + restSeconds: + restSeconds == _sentinel ? this.restSeconds : restSeconds as int, + tempo: tempo == _sentinel ? this.tempo : tempo as String?, + weightPercentage: weightPercentage == _sentinel + ? this.weightPercentage + : weightPercentage as double?, + notes: notes == _sentinel ? this.notes : notes as String?, + supersetGroupId: supersetGroupId == _sentinel + ? this.supersetGroupId + : supersetGroupId as String?, + ); +} + +/// One training day inside a program week. +class ProgramDay { + final String id; + final String name; // e.g. "Push", "Pull", "Legs", "Core" + final int? dayOfWeek; // 1=Mon … 7=Sun; null = unscheduled + final String? notes; + final List exercises; + + ProgramDay({ + required this.id, + required this.name, + this.dayOfWeek, + this.notes, + required this.exercises, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'dayOfWeek': dayOfWeek, + 'notes': notes, + 'exercises': exercises.map((e) => e.toJson()).toList(), + }; + + factory ProgramDay.fromJson(Map json) => ProgramDay( + id: json['id'] as String, + name: json['name'] as String, + dayOfWeek: json['dayOfWeek'] as int?, + notes: json['notes'] as String?, + exercises: (json['exercises'] as List) + .map((e) => ProgramExerciseSlot.fromJson(e as Map)) + .toList(), + ); + + ProgramDay copyWith({ + Object? id = _sentinel, + Object? name = _sentinel, + Object? dayOfWeek = _sentinel, + Object? notes = _sentinel, + Object? exercises = _sentinel, + }) => ProgramDay( + id: id == _sentinel ? this.id : id as String, + name: name == _sentinel ? this.name : name as String, + dayOfWeek: dayOfWeek == _sentinel ? this.dayOfWeek : dayOfWeek as int?, + notes: notes == _sentinel ? this.notes : notes as String?, + exercises: exercises == _sentinel + ? this.exercises + : exercises as List, + ); +} + +/// One week of a training program. +/// +/// A deload week lowers volume/intensity to facilitate recovery. +/// [deloadIntensityFactor] of 0.85 means 85% of normal weight. +/// [deloadSetReduction] removes N sets per exercise (e.g. 1 or 2). +class ProgramWeek { + final int weekNumber; // 1-based + final bool isDeload; + final double deloadIntensityFactor; // 0.0–1.0; default 1.0 (no change) + final int deloadSetReduction; // sets removed per exercise on deload + final String? phaseId; + final String? notes; + final List days; + + ProgramWeek({ + required this.weekNumber, + this.isDeload = false, + this.deloadIntensityFactor = 1.0, + this.deloadSetReduction = 0, + this.phaseId, + this.notes, + required this.days, + }); + + Map toJson() => { + 'weekNumber': weekNumber, + 'isDeload': isDeload, + 'deloadIntensityFactor': deloadIntensityFactor, + 'deloadSetReduction': deloadSetReduction, + 'phaseId': phaseId, + 'notes': notes, + 'days': days.map((d) => d.toJson()).toList(), + }; + + factory ProgramWeek.fromJson(Map json) => ProgramWeek( + weekNumber: json['weekNumber'] as int, + isDeload: json['isDeload'] as bool? ?? false, + deloadIntensityFactor: + (json['deloadIntensityFactor'] as num?)?.toDouble() ?? 1.0, + deloadSetReduction: json['deloadSetReduction'] as int? ?? 0, + phaseId: json['phaseId'] as String?, + notes: json['notes'] as String?, + days: (json['days'] as List) + .map((d) => ProgramDay.fromJson(d as Map)) + .toList(), + ); + + ProgramWeek copyWith({ + Object? weekNumber = _sentinel, + Object? isDeload = _sentinel, + Object? deloadIntensityFactor = _sentinel, + Object? deloadSetReduction = _sentinel, + Object? phaseId = _sentinel, + Object? notes = _sentinel, + Object? days = _sentinel, + }) => ProgramWeek( + weekNumber: weekNumber == _sentinel ? this.weekNumber : weekNumber as int, + isDeload: isDeload == _sentinel ? this.isDeload : isDeload as bool, + deloadIntensityFactor: deloadIntensityFactor == _sentinel + ? this.deloadIntensityFactor + : deloadIntensityFactor as double, + deloadSetReduction: deloadSetReduction == _sentinel + ? this.deloadSetReduction + : deloadSetReduction as int, + phaseId: phaseId == _sentinel ? this.phaseId : phaseId as String?, + notes: notes == _sentinel ? this.notes : notes as String?, + days: days == _sentinel ? this.days : days as List, + ); +} + +/// Named training phase (e.g. "Foundation", "Intensify", "Peak"). +class TrainingPhase { + final String id; + final String name; + final int startWeek; // 1-based, inclusive + final int endWeek; // 1-based, inclusive + final String? notes; + final String? colorHex; // optional override for UI + + TrainingPhase({ + required this.id, + required this.name, + required this.startWeek, + required this.endWeek, + this.notes, + this.colorHex, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'startWeek': startWeek, + 'endWeek': endWeek, + 'notes': notes, + 'colorHex': colorHex, + }; + + factory TrainingPhase.fromJson(Map json) => TrainingPhase( + id: json['id'] as String, + name: json['name'] as String, + startWeek: json['startWeek'] as int, + endWeek: json['endWeek'] as int, + notes: json['notes'] as String?, + colorHex: json['colorHex'] as String?, + ); + + TrainingPhase copyWith({ + Object? id = _sentinel, + Object? name = _sentinel, + Object? startWeek = _sentinel, + Object? endWeek = _sentinel, + Object? notes = _sentinel, + Object? colorHex = _sentinel, + }) => TrainingPhase( + id: id == _sentinel ? this.id : id as String, + name: name == _sentinel ? this.name : name as String, + startWeek: startWeek == _sentinel ? this.startWeek : startWeek as int, + endWeek: endWeek == _sentinel ? this.endWeek : endWeek as int, + notes: notes == _sentinel ? this.notes : notes as String?, + colorHex: colorHex == _sentinel ? this.colorHex : colorHex as String?, + ); +} + +/// Top-level training program (e.g. "12-Week Hypertrophy Block"). +/// +/// Contains an ordered list of [ProgramWeek]s and named [TrainingPhase]s. +/// Can be created in-app or imported from a JSON file. +class TrainingProgram { + final String id; + final String name; + final String? description; + final int totalWeeks; + final List phases; + final List weeks; + final String? author; + final bool isImported; + final DateTime createdAt; + + TrainingProgram({ + required this.id, + required this.name, + this.description, + required this.totalWeeks, + required this.phases, + required this.weeks, + this.author, + this.isImported = false, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + /// Returns the phase that contains [weekNumber], or null. + TrainingPhase? phaseForWeek(int weekNumber) { + for (final phase in phases) { + if (weekNumber >= phase.startWeek && weekNumber <= phase.endWeek) { + return phase; + } + } + return null; + } + + /// Number of training days across the entire program. + int get totalDays => + weeks.fold(0, (sum, w) => sum + w.days.length); + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + 'totalWeeks': totalWeeks, + 'phases': phases.map((p) => p.toJson()).toList(), + 'weeks': weeks.map((w) => w.toJson()).toList(), + 'author': author, + 'isImported': isImported, + 'createdAt': createdAt.toIso8601String(), + }; + + factory TrainingProgram.fromJson(Map json) => + TrainingProgram( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + totalWeeks: json['totalWeeks'] as int, + phases: (json['phases'] as List) + .map((p) => TrainingPhase.fromJson(p as Map)) + .toList(), + weeks: (json['weeks'] as List) + .map((w) => ProgramWeek.fromJson(w as Map)) + .toList(), + author: json['author'] as String?, + isImported: json['isImported'] as bool? ?? false, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : DateTime.now(), + ); + + TrainingProgram copyWith({ + Object? id = _sentinel, + Object? name = _sentinel, + Object? description = _sentinel, + Object? totalWeeks = _sentinel, + Object? phases = _sentinel, + Object? weeks = _sentinel, + Object? author = _sentinel, + Object? isImported = _sentinel, + Object? createdAt = _sentinel, + }) => TrainingProgram( + id: id == _sentinel ? this.id : id as String, + name: name == _sentinel ? this.name : name as String, + description: + description == _sentinel ? this.description : description as String?, + totalWeeks: totalWeeks == _sentinel ? this.totalWeeks : totalWeeks as int, + phases: phases == _sentinel ? this.phases : phases as List, + weeks: weeks == _sentinel ? this.weeks : weeks as List, + author: author == _sentinel ? this.author : author as String?, + isImported: isImported == _sentinel ? this.isImported : isImported as bool, + createdAt: + createdAt == _sentinel ? this.createdAt : createdAt as DateTime, + ); +} diff --git a/workout-logger/lib/screens/programs/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart new file mode 100644 index 0000000..e9f0542 --- /dev/null +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -0,0 +1,926 @@ +// Program Designer Screen +// +// Multi-step UI to create or edit a training program: +// Step 1: Metadata (name, description, author, total weeks, phases) +// Step 2: Week structure (mark deload weeks, add days per week) +// Step 3: Exercise slots per day (sets, rep range, rest, tempo, weight%, notes) + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +class ProgramDesignerScreen extends StatefulWidget { + final TrainingProgram? existing; + + const ProgramDesignerScreen({super.key, this.existing}); + + @override + State createState() => _ProgramDesignerScreenState(); +} + +class _ProgramDesignerScreenState extends State { + final _uuid = const Uuid(); + int _step = 0; + + // ── Step 1 State ───────────────────────────────────────────────────── + final _nameCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final _authorCtrl = TextEditingController(); + int _totalWeeks = 12; + final List _phases = []; + + // ── Step 2 State ───────────────────────────────────────────────────── + late List _weeks; + + // ── Step 3 State ───────────────────────────────────────────────────── + // (editing happens inline inside _weeks list) + + @override + void initState() { + super.initState(); + if (widget.existing != null) { + final p = widget.existing!; + _nameCtrl.text = p.name; + _descCtrl.text = p.description ?? ''; + _authorCtrl.text = p.author ?? ''; + _totalWeeks = p.totalWeeks; + _phases.addAll(p.phases); + _weeks = p.weeks.map((w) => w).toList(); + } else { + _weeks = []; + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + _descCtrl.dispose(); + _authorCtrl.dispose(); + super.dispose(); + } + + // ── Build ──────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + appBar: AppBar( + title: Text(widget.existing == null ? 'New Program' : 'Edit Program'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(4), + child: LinearProgressIndicator( + value: (_step + 1) / 3, + backgroundColor: AppTheme.surfaceColor, + color: AppTheme.primaryColor, + ), + ), + ), + body: IndexedStack( + index: _step, + children: [ + _buildStep1(), + _buildStep2(), + _buildStep3(), + ], + ), + bottomNavigationBar: _buildNavBar(), + ); + } + + Widget _buildNavBar() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + if (_step > 0) + OutlinedButton( + onPressed: () => setState(() => _step--), + child: const Text('Back'), + ), + const Spacer(), + Text( + 'Step ${_step + 1} of 3', + style: const TextStyle( + color: AppTheme.textMuted, + fontSize: 12, + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _step < 2 ? _nextStep : _save, + child: Text(_step < 2 ? 'Next' : 'Save'), + ), + ], + ), + ), + ); + } + + // ── Step 1: Metadata ────────────────────────────────────────────────── + + Widget _buildStep1() { + return ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + const _SectionHeader('Program Details'), + _field(_nameCtrl, 'Program Name *', hint: 'e.g. 12-Week Hypertrophy'), + const SizedBox(height: AppSpacing.sm), + _field(_descCtrl, 'Description', maxLines: 3), + const SizedBox(height: AppSpacing.sm), + _field(_authorCtrl, 'Author / Coach', hint: 'Optional'), + const SizedBox(height: AppSpacing.lg), + const _SectionHeader('Duration'), + _NumberStepper( + label: 'Total Weeks', + value: _totalWeeks, + min: 1, + max: 52, + onChanged: (v) { + setState(() { + _totalWeeks = v; + _rebuildWeeks(); + }); + }, + ), + const SizedBox(height: AppSpacing.lg), + const _SectionHeader('Phases (optional)'), + ..._phases.asMap().entries.map( + (entry) => _buildPhaseChip(entry.key, entry.value), + ), + const SizedBox(height: AppSpacing.sm), + OutlinedButton.icon( + onPressed: _addPhase, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Phase'), + ), + ], + ); + } + + Widget _buildPhaseChip(int idx, TrainingPhase phase) { + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + dense: true, + title: Text(phase.name), + subtitle: Text('Weeks ${phase.startWeek}–${phase.endWeek}'), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.error), + onPressed: () => setState(() => _phases.removeAt(idx)), + ), + onTap: () => _editPhase(idx, phase), + ), + ); + } + + void _addPhase() => _showPhaseDialog(null, null); + void _editPhase(int idx, TrainingPhase phase) => _showPhaseDialog(idx, phase); + + void _showPhaseDialog(int? idx, TrainingPhase? existing) { + final nameCtrl = TextEditingController(text: existing?.name ?? ''); + int start = existing?.startWeek ?? 1; + int end = existing?.endWeek ?? _totalWeeks; + + showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (ctx, setDlg) => AlertDialog( + title: Text(existing == null ? 'Add Phase' : 'Edit Phase'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: 'Phase Name', + hintText: 'e.g. Foundation', + ), + ), + const SizedBox(height: AppSpacing.md), + _NumberStepper( + label: 'Start Week', + value: start, + min: 1, + max: _totalWeeks, + onChanged: (v) => setDlg(() => start = v), + ), + _NumberStepper( + label: 'End Week', + value: end, + min: start, + max: _totalWeeks, + onChanged: (v) => setDlg(() => end = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final phase = TrainingPhase( + id: existing?.id ?? _uuid.v4(), + name: nameCtrl.text.isEmpty ? 'Phase' : nameCtrl.text, + startWeek: start, + endWeek: end, + ); + setState(() { + if (idx != null) { + _phases[idx] = phase; + } else { + _phases.add(phase); + } + }); + Navigator.pop(ctx); + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + } + + // ── Step 2: Week Structure ───────────────────────────────────────────── + + Widget _buildStep2() { + return ListView.builder( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _weeks.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const _SectionHeader('Weeks & Days'); + } + final week = _weeks[index - 1]; + return _buildWeekEditor(index - 1, week); + }, + ); + } + + Widget _buildWeekEditor(int idx, ProgramWeek week) { + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ExpansionTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: week.isDeload + ? Colors.amber.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: week.isDeload ? Colors.amber : AppTheme.primaryColor, + ), + ), + ), + title: Text( + week.isDeload ? 'Week ${week.weekNumber} — Deload' : 'Week ${week.weekNumber}', + style: TextStyle( + fontWeight: FontWeight.w600, + color: week.isDeload ? Colors.amber : AppTheme.textPrimary, + ), + ), + subtitle: Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Deload toggle + SwitchListTile.adaptive( + dense: true, + contentPadding: EdgeInsets.zero, + title: const Text('Deload Week'), + value: week.isDeload, + onChanged: (v) => setState(() { + _weeks[idx] = week.copyWith(isDeload: v); + }), + ), + if (week.isDeload) ...[ + _NumberStepper( + label: 'Intensity factor (%)', + value: (week.deloadIntensityFactor * 100).round(), + min: 50, + max: 95, + onChanged: (v) => setState(() { + _weeks[idx] = week.copyWith( + deloadIntensityFactor: v / 100.0, + ); + }), + ), + _NumberStepper( + label: 'Sets reduced by', + value: week.deloadSetReduction, + min: 0, + max: 3, + onChanged: (v) => setState(() { + _weeks[idx] = week.copyWith(deloadSetReduction: v); + }), + ), + ], + const Divider(), + // Days in this week + ...week.days.asMap().entries.map( + (entry) => _buildDayChip(idx, entry.key, entry.value), + ), + OutlinedButton.icon( + onPressed: () => _addDay(idx), + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Day'), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDayChip(int weekIdx, int dayIdx, ProgramDay day) { + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.drag_handle, color: AppTheme.textMuted), + title: Text(day.name), + subtitle: Text( + '${day.exercises.length} exercise${day.exercises.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 11), + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.error, size: 18), + onPressed: () => setState(() { + final days = List.from(_weeks[weekIdx].days) + ..removeAt(dayIdx); + _weeks[weekIdx] = _weeks[weekIdx].copyWith(days: days); + }), + ), + ); + } + + void _addDay(int weekIdx) { + showDialog( + context: context, + builder: (_) { + final nameCtrl = TextEditingController(); + int? dow; + return StatefulBuilder( + builder: (ctx, setDlg) => AlertDialog( + title: const Text('Add Day'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration( + labelText: 'Day Name', + hintText: 'e.g. Push, Pull, Legs', + ), + ), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Day of Week (optional)', + ), + value: dow, + items: [ + const DropdownMenuItem(value: null, child: Text('Unscheduled')), + ...List.generate(7, (i) => i + 1).map((d) => DropdownMenuItem( + value: d, + child: Text( + ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][d - 1], + ), + )), + ], + onChanged: (v) => setDlg(() => dow = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final newDay = ProgramDay( + id: _uuid.v4(), + name: nameCtrl.text.isEmpty ? 'Day' : nameCtrl.text, + dayOfWeek: dow, + exercises: [], + ); + setState(() { + final days = List.from(_weeks[weekIdx].days) + ..add(newDay); + _weeks[weekIdx] = _weeks[weekIdx].copyWith(days: days); + }); + Navigator.pop(ctx); + }, + child: const Text('Add'), + ), + ], + ), + ); + }, + ); + } + + // ── Step 3: Exercises per Day ────────────────────────────────────────── + + Widget _buildStep3() { + final allExercises = context.read().allExercises; + + final items = [const _SectionHeader('Exercises per Day')]; + + for (int wi = 0; wi < _weeks.length; wi++) { + final week = _weeks[wi]; + items.add(Padding( + padding: const EdgeInsets.fromLTRB(0, AppSpacing.md, 0, AppSpacing.sm), + child: Text( + 'Week ${week.weekNumber}${week.isDeload ? ' — Deload' : ''}', + style: const TextStyle( + color: AppTheme.textSecondary, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + )); + for (int di = 0; di < week.days.length; di++) { + items.add(_buildDayExerciseEditor(wi, di, week.days[di], allExercises)); + } + } + + return ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: items, + ); + } + + Widget _buildDayExerciseEditor( + int weekIdx, + int dayIdx, + ProgramDay day, + List allExercises, + ) { + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ExpansionTile( + title: Text( + day.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + 'Week ${_weeks[weekIdx].weekNumber} · ${day.exercises.length} exercise${day.exercises.length != 1 ? 's' : ''}', + style: const TextStyle(fontSize: 11), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Column( + children: [ + ...day.exercises.asMap().entries.map( + (entry) => _buildSlotEditor( + weekIdx, + dayIdx, + entry.key, + entry.value, + allExercises, + ), + ), + const SizedBox(height: AppSpacing.sm), + OutlinedButton.icon( + onPressed: () => _addExerciseSlot( + weekIdx, + dayIdx, + allExercises, + ), + icon: const Icon(Icons.add, size: 16), + label: const Text('Add Exercise'), + ), + const SizedBox(height: AppSpacing.md), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSlotEditor( + int weekIdx, + int dayIdx, + int slotIdx, + ProgramExerciseSlot slot, + List allExercises, + ) { + final exercise = allExercises.where((e) => e.id == slot.exerciseId).firstOrNull; + final name = exercise?.name ?? slot.exerciseId; + + return Card( + color: AppTheme.surfaceColor, + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + dense: true, + title: Text(name, style: const TextStyle(fontSize: 13)), + subtitle: Text( + '${slot.sets}×${slot.minReps}–${slot.maxReps} · ${slot.restSeconds}s' + '${slot.tempo != null ? ' · ${slot.tempo}' : ''}' + '${slot.weightPercentage != null ? ' · ${slot.weightPercentage!.toStringAsFixed(0)}%' : ''}', + style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => _editSlot(weekIdx, dayIdx, slotIdx, slot, allExercises), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppTheme.error, size: 18), + onPressed: () => setState(() { + final exercises = List.from( + _weeks[weekIdx].days[dayIdx].exercises, + )..removeAt(slotIdx); + _updateDayExercises(weekIdx, dayIdx, exercises); + }), + ), + ], + ), + ), + ); + } + + void _addExerciseSlot(int weekIdx, int dayIdx, List allExercises) { + _showSlotDialog(weekIdx, dayIdx, null, null, allExercises); + } + + void _editSlot( + int weekIdx, + int dayIdx, + int slotIdx, + ProgramExerciseSlot slot, + List allExercises, + ) { + _showSlotDialog(weekIdx, dayIdx, slotIdx, slot, allExercises); + } + + void _showSlotDialog( + int weekIdx, + int dayIdx, + int? slotIdx, + ProgramExerciseSlot? existing, + List allExercises, + ) { + String? selectedExerciseId = existing?.exerciseId; + int sets = existing?.sets ?? 3; + int minReps = existing?.minReps ?? 8; + int maxReps = existing?.maxReps ?? 12; + int restSec = existing?.restSeconds ?? 90; + final tempoCtrl = TextEditingController(text: existing?.tempo ?? ''); + final weightPctCtrl = TextEditingController( + text: existing?.weightPercentage?.toStringAsFixed(0) ?? '', + ); + final notesCtrl = TextEditingController(text: existing?.notes ?? ''); + String exerciseSearch = ''; + + showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (ctx, setDlg) { + final filtered = allExercises + .where( + (e) => e.name.toLowerCase().contains(exerciseSearch.toLowerCase()), + ) + .take(20) + .toList(); + + return AlertDialog( + title: Text(existing == null ? 'Add Exercise' : 'Edit Exercise'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: const InputDecoration( + labelText: 'Search exercise', + prefixIcon: Icon(Icons.search, size: 18), + isDense: true, + ), + onChanged: (v) => setDlg(() => exerciseSearch = v), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 140, + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, i) => ListTile( + dense: true, + title: Text(filtered[i].name, style: const TextStyle(fontSize: 13)), + selected: selectedExerciseId == filtered[i].id, + selectedColor: AppTheme.primaryColor, + onTap: () => setDlg(() => selectedExerciseId = filtered[i].id), + ), + ), + ), + const Divider(), + _NumberStepper( + label: 'Sets', + value: sets, + min: 1, + max: 10, + onChanged: (v) => setDlg(() => sets = v), + ), + _NumberStepper( + label: 'Min Reps', + value: minReps, + min: 1, + max: maxReps, + onChanged: (v) => setDlg(() => minReps = v), + ), + _NumberStepper( + label: 'Max Reps', + value: maxReps, + min: minReps, + max: 50, + onChanged: (v) => setDlg(() => maxReps = v), + ), + _NumberStepper( + label: 'Rest (seconds)', + value: restSec, + min: 15, + max: 300, + step: 15, + onChanged: (v) => setDlg(() => restSec = v), + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: tempoCtrl, + decoration: const InputDecoration( + labelText: 'Tempo (e.g. 3-1-1)', + isDense: true, + ), + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: weightPctCtrl, + decoration: const InputDecoration( + labelText: 'Weight % (e.g. 70)', + isDense: true, + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: notesCtrl, + decoration: const InputDecoration( + labelText: 'Notes', + isDense: true, + ), + maxLines: 2, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + if (selectedExerciseId == null) return; + final slot = ProgramExerciseSlot( + exerciseId: selectedExerciseId!, + sets: sets, + minReps: minReps, + maxReps: maxReps, + restSeconds: restSec, + tempo: tempoCtrl.text.isEmpty ? null : tempoCtrl.text, + weightPercentage: double.tryParse(weightPctCtrl.text), + notes: notesCtrl.text.isEmpty ? null : notesCtrl.text, + ); + setState(() { + final exercises = List.from( + _weeks[weekIdx].days[dayIdx].exercises, + ); + if (slotIdx != null) { + exercises[slotIdx] = slot; + } else { + exercises.add(slot); + } + _updateDayExercises(weekIdx, dayIdx, exercises); + }); + Navigator.pop(ctx); + }, + child: const Text('Save'), + ), + ], + ); + }, + ), + ); + } + + void _updateDayExercises( + int weekIdx, + int dayIdx, + List exercises, + ) { + final days = List.from(_weeks[weekIdx].days); + days[dayIdx] = days[dayIdx].copyWith(exercises: exercises); + _weeks[weekIdx] = _weeks[weekIdx].copyWith(days: days); + } + + // ── Navigation ─────────────────────────────────────────────────────── + + void _nextStep() { + if (_step == 0) { + if (_nameCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a program name to continue')), + ); + return; + } + _rebuildWeeks(); + } + setState(() => _step++); + } + + void _rebuildWeeks() { + // Keep existing weeks, only add/remove to match _totalWeeks + if (_weeks.length < _totalWeeks) { + final phaseId = _phases.isNotEmpty ? _phases.first.id : null; + for (int i = _weeks.length + 1; i <= _totalWeeks; i++) { + _weeks.add( + ProgramWeek(weekNumber: i, phaseId: phaseId, days: []), + ); + } + } else if (_weeks.length > _totalWeeks) { + _weeks = _weeks.sublist(0, _totalWeeks); + } + // Assign phase ids based on phase ranges + for (int i = 0; i < _weeks.length; i++) { + final weekNum = i + 1; + String? pid; + for (final phase in _phases) { + if (weekNum >= phase.startWeek && weekNum <= phase.endWeek) { + pid = phase.id; + break; + } + } + _weeks[i] = _weeks[i].copyWith(phaseId: pid); + } + } + + // ── Save ───────────────────────────────────────────────────────────── + + Future _save() async { + if (_nameCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Program name is required')), + ); + return; + } + + final provider = context.read(); + final program = TrainingProgram( + id: widget.existing?.id ?? _uuid.v4(), + name: _nameCtrl.text.trim(), + description: _descCtrl.text.isEmpty ? null : _descCtrl.text, + author: _authorCtrl.text.isEmpty ? null : _authorCtrl.text, + totalWeeks: _totalWeeks, + phases: _phases, + weeks: _weeks, + isImported: false, + createdAt: widget.existing?.createdAt ?? DateTime.now(), + ); + + await provider.programManager.saveProgram(program); + if (mounted) Navigator.pop(context); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + Widget _field( + TextEditingController ctrl, + String label, { + String? hint, + int maxLines = 1, + }) { + return TextField( + controller: ctrl, + maxLines: maxLines, + decoration: InputDecoration(labelText: label, hintText: hint), + ); + } +} + +// ── Shared Widgets ────────────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + final String text; + + const _SectionHeader(this.text); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + text.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textMuted, + letterSpacing: 1.2, + ), + ), + ); + } +} + +class _NumberStepper extends StatelessWidget { + final String label; + final int value; + final int min; + final int max; + final int step; + final ValueChanged onChanged; + + const _NumberStepper({ + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.step = 1, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ), + IconButton( + icon: const Icon(Icons.remove, size: 18), + onPressed: value > min + ? () => onChanged((value - step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + SizedBox( + width: 40, + child: Text( + '$value', + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 18), + onPressed: value < max + ? () => onChanged((value + step).clamp(min, max)) + : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ); + } +} diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart new file mode 100644 index 0000000..2d62f49 --- /dev/null +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -0,0 +1,766 @@ +// Program Detail Screen +// +// Shows a full training program: phase timeline, week list with deload badges, +// and per-day exercise details (sets, rep range, rest, tempo, weight%, notes). + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'dart:ui' show FontFeature; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +class ProgramDetailScreen extends StatefulWidget { + final TrainingProgram program; + + const ProgramDetailScreen({super.key, required this.program}); + + @override + State createState() => _ProgramDetailScreenState(); +} + +class _ProgramDetailScreenState extends State { + late TrainingProgram _program; + int? _expandedWeekIndex; + + @override + void initState() { + super.initState(); + _program = widget.program; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + appBar: AppBar( + title: Text(_program.name), + actions: [ + PopupMenuButton( + onSelected: _handleMenuAction, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'export', child: Text('Export JSON')), + const PopupMenuItem( + value: 'delete', + child: Text('Delete', style: TextStyle(color: AppTheme.error)), + ), + ], + ), + ], + ), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _buildHeader()), + if (_program.phases.isNotEmpty) + SliverToBoxAdapter(child: _buildPhaseTimeline()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: Text( + 'WEEKS', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textMuted, + letterSpacing: 1.2, + ), + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildWeekTile(index), + childCount: _program.weeks.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xxl)), + ], + ), + ); + } + + // ── Header ────────────────────────────────────────────────────────────── + + Widget _buildHeader() { + final deloadCount = _program.weeks.where((w) => w.isDeload).length; + + return Container( + margin: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: AppTheme.primaryColor.withOpacity(0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_program.description != null) ...[ + Text( + _program.description!, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + ], + Row( + children: [ + _statChip( + Icons.calendar_today, + '${_program.totalWeeks} weeks', + AppTheme.primaryColor, + ), + const SizedBox(width: AppSpacing.sm), + _statChip( + Icons.bolt, + '${_program.phases.length} phases', + AppTheme.secondaryColor, + ), + const SizedBox(width: AppSpacing.sm), + if (deloadCount > 0) + _statChip( + Icons.battery_charging_full, + '$deloadCount deload${deloadCount > 1 ? 's' : ''}', + Colors.amber, + ), + ], + ), + if (_program.author != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + 'by ${_program.author}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + ), + ], + if (_program.isImported) ...[ + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + const Icon(Icons.download, size: 12, color: AppTheme.textMuted), + const SizedBox(width: 4), + Text( + 'Imported', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textMuted), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _statChip(IconData icon, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + // ── Phase Timeline ──────────────────────────────────────────────────── + + static const List _phaseColors = [ + AppTheme.primaryColor, + AppTheme.secondaryColor, + Colors.orange, + Colors.pink, + Colors.green, + ]; + + Widget _buildPhaseTimeline() { + return Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'PHASES', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.textMuted, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: AppSpacing.sm), + // Visual timeline bar + SizedBox( + height: 8, + child: Row( + children: _program.phases.asMap().entries.map((entry) { + final phase = entry.value; + final fraction = + (phase.endWeek - phase.startWeek + 1) / _program.totalWeeks; + final color = + _phaseColors[entry.key % _phaseColors.length]; + return Expanded( + flex: ((fraction * 100).round()).clamp(1, 100), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: 4, + children: _program.phases.asMap().entries.map((entry) { + final phase = entry.value; + final color = _phaseColors[entry.key % _phaseColors.length]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + '${phase.name} (W${phase.startWeek}–${phase.endWeek})', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ); + }).toList(), + ), + ], + ), + ); + } + + // ── Week Tile ──────────────────────────────────────────────────────── + + Widget _buildWeekTile(int index) { + final week = _program.weeks[index]; + final isExpanded = _expandedWeekIndex == index; + final phase = _program.phaseForWeek(week.weekNumber); + final phaseIdx = phase == null + ? 0 + : _program.phases.indexWhere((p) => p.id == phase.id); + final phaseColor = phaseIdx >= 0 + ? _phaseColors[phaseIdx % _phaseColors.length] + : AppTheme.primaryColor; + + return Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: week.isDeload + ? Border.all(color: Colors.amber.withOpacity(0.5), width: 1) + : null, + ), + child: Column( + children: [ + InkWell( + onTap: () => setState(() { + _expandedWeekIndex = isExpanded ? null : index; + }), + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: phaseColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Text( + 'W${week.weekNumber}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: phaseColor, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) ...[ + const Icon( + Icons.battery_charging_full, + size: 14, + color: Colors.amber, + ), + const SizedBox(width: 4), + const Text( + 'DELOAD ', + style: TextStyle( + fontSize: 11, + color: Colors.amber, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ], + if (phase != null) + Text( + phase.name, + style: TextStyle( + fontSize: 12, + color: phaseColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Text( + '${week.days.length} day${week.days.length != 1 ? 's' : ''}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + if (week.isDeload) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Text( + '${((week.deloadIntensityFactor) * 100).round()}%', + style: const TextStyle( + fontSize: 12, + color: Colors.amber, + fontWeight: FontWeight.bold, + ), + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: AppTheme.textMuted, + size: 20, + ), + ], + ), + ), + ), + if (isExpanded) ...[ + const Divider( + height: 1, + color: AppTheme.surfaceColor, + indent: AppSpacing.md, + endIndent: AppSpacing.md, + ), + ...week.days.map( + (day) => _buildDaySection(day, week), + ), + if (week.notes != null) + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.md, + ), + child: Row( + children: [ + const Icon( + Icons.info_outline, + size: 14, + color: AppTheme.textMuted, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + week.notes!, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + // ── Day Section ───────────────────────────────────────────────────── + + Widget _buildDaySection(ProgramDay day, ProgramWeek week) { + final provider = context.read(); + + // Group exercises by superset group + final supersetGroups = >{}; + for (final slot in day.exercises) { + final key = slot.supersetGroupId; + supersetGroups.putIfAbsent(key, () => []).add(slot); + } + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + day.name.toUpperCase(), + style: const TextStyle( + fontSize: 11, + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + if (day.dayOfWeek != null) ...[ + const SizedBox(width: AppSpacing.sm), + Text( + _dayName(day.dayOfWeek!), + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + ), + ), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + // Render standalone exercises and superset groups + ...supersetGroups.entries.map((entry) { + final slots = entry.value; + final isSuperset = entry.key != null; + if (isSuperset) { + return _buildSupersetGroup(slots, provider, week); + } + return Column( + children: slots + .map((slot) => _buildExerciseRow(slot, provider, week)) + .toList(), + ); + }), + ], + ), + ); + } + + Widget _buildSupersetGroup( + List slots, + WorkoutProvider provider, + ProgramWeek week, + ) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: AppTheme.secondaryColor.withOpacity(0.6), + width: 3, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: AppSpacing.sm, bottom: 2), + child: Text( + 'SUPERSET', + style: TextStyle( + fontSize: 10, + color: AppTheme.secondaryColor, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), + ), + ), + ...slots.map( + (slot) => _buildExerciseRow(slot, provider, week, indent: true), + ), + ], + ), + ); + } + + Widget _buildExerciseRow( + ProgramExerciseSlot slot, + WorkoutProvider provider, + ProgramWeek week, { + bool indent = false, + }) { + final exercise = provider.getExercise(slot.exerciseId); + final name = exercise?.name ?? slot.exerciseId; + + // Apply deload adjustments for display + final displaySets = + week.isDeload ? (slot.sets - week.deloadSetReduction).clamp(1, 99) : slot.sets; + final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; + + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps}' + : '${slot.minReps}–${slot.maxReps}'; + + return Padding( + padding: EdgeInsets.only( + left: indent ? AppSpacing.md : 0, + bottom: AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Sets × Reps badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.surfaceColor, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + '$displaySets × $repRange', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: AppSpacing.sm, + runSpacing: 2, + children: [ + _infoChip( + Icons.timer_outlined, + '${slot.restSeconds}s rest', + ), + if (slot.tempo != null) + _infoChip(Icons.speed, slot.tempo!), + if (slot.weightPercentage != null) + _infoChip( + Icons.fitness_center, + week.isDeload + ? '${(slot.weightPercentage! * displayIntensity).toStringAsFixed(0)}%' + : '${slot.weightPercentage!.toStringAsFixed(0)}%', + ), + ], + ), + if (slot.notes != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _infoChip(IconData icon, String label) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: AppTheme.textMuted), + const SizedBox(width: 2), + Text( + label, + style: const TextStyle(fontSize: 11, color: AppTheme.textMuted), + ), + ], + ); + } + + // ── Actions ────────────────────────────────────────────────────────── + + void _handleMenuAction(String action) async { + switch (action) { + case 'export': + _exportProgram(); + case 'delete': + _confirmDelete(); + } + } + + void _exportProgram() { + final provider = context.read(); + final json = provider.programManager.exportToJson(_program); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Export Program'), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: SelectableText( + json, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: AppTheme.textSecondary, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: json)); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('JSON copied to clipboard')), + ); + }, + child: const Text('Copy to Clipboard'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + + void _confirmDelete() { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete Program?'), + content: Text('Delete "${_program.name}"? This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final provider = context.read(); + await provider.programManager.deleteProgram(_program.id); + if (mounted) { + Navigator.pop(context); // close dialog + Navigator.pop(context); // go back to list + } + }, + child: const Text( + 'Delete', + style: TextStyle(color: AppTheme.error), + ), + ), + ], + ), + ); + } + + // ── Utils ──────────────────────────────────────────────────────────── + + static const _dayNames = [ + '', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]; + + String _dayName(int dow) => dow >= 1 && dow <= 7 ? _dayNames[dow] : ''; +} diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart new file mode 100644 index 0000000..5ea1300 --- /dev/null +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -0,0 +1,407 @@ +// Programs Screen +// +// Shows the list of training programs and provides entry points for: +// - Viewing program details +// - Creating a new program +// - Importing a program from JSON (paste dialog) + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../models/models.dart'; +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; +import 'program_detail_screen.dart'; +import 'program_designer_screen.dart'; + +class ProgramsScreen extends StatelessWidget { + const ProgramsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: context.read().programManager, + builder: (context, _) { + final programs = + context.read().programManager.programs; + + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + body: programs.isEmpty + ? _buildEmptyState(context) + : _buildList(context, programs), + floatingActionButton: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.small( + heroTag: 'import_json', + onPressed: () => _showImportDialog(context), + backgroundColor: AppTheme.surfaceColor, + child: const Icon(Icons.download, color: AppTheme.secondaryColor), + ), + const SizedBox(height: AppSpacing.sm), + FloatingActionButton.extended( + heroTag: 'new_program', + onPressed: () => _openDesigner(context, null), + icon: const Icon(Icons.add), + label: const Text('New Program'), + ), + ], + ), + ); + }, + ); + } + + // ── Empty State ────────────────────────────────────────────────────── + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.calendar_month, size: 64, color: AppTheme.textMuted), + const SizedBox(height: 16), + Text( + 'No Training Programs', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Create a structured multi-week program\nor import one from JSON', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => _openDesigner(context, null), + icon: const Icon(Icons.add), + label: const Text('Create'), + ), + const SizedBox(width: AppSpacing.md), + OutlinedButton.icon( + onPressed: () => _showImportDialog(context), + icon: const Icon(Icons.download), + label: const Text('Import JSON'), + ), + ], + ), + ], + ), + ); + } + + // ── Program List ───────────────────────────────────────────────────── + + Widget _buildList(BuildContext context, List programs) { + return ListView.builder( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + 100, // FAB clearance + ), + itemCount: programs.length, + itemBuilder: (context, index) => + _ProgramCard(program: programs[index]), + ); + } + + // ── Actions ────────────────────────────────────────────────────────── + + void _openDesigner(BuildContext context, TrainingProgram? existing) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDesignerScreen(existing: existing), + ), + ); + } + + void _showImportDialog(BuildContext context) { + final ctrl = TextEditingController(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.cardColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => Padding( + padding: EdgeInsets.only( + left: AppSpacing.lg, + right: AppSpacing.lg, + top: AppSpacing.lg, + bottom: MediaQuery.of(context).viewInsets.bottom + AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Import Program from JSON', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Paste a valid TrainingProgram JSON below.\n' + 'The program must include: name, totalWeeks, phases, weeks.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: ctrl, + maxLines: 8, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + decoration: const InputDecoration( + hintText: '{ "name": "...", "totalWeeks": 12, ... }', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: AppSpacing.sm), + ElevatedButton.icon( + onPressed: () => _doImport(context, ctrl.text), + icon: const Icon(Icons.download), + label: const Text('Import'), + ), + ], + ), + ], + ), + ), + ); + } + + Future _doImport(BuildContext context, String json) async { + if (json.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please paste JSON before importing')), + ); + return; + } + + try { + final provider = context.read(); + await provider.programManager.importFromJson(json.trim()); + if (context.mounted) { + Navigator.pop(context); // close bottom sheet + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Program imported successfully!')), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Import failed: ${_friendlyError(e)}'), + backgroundColor: AppTheme.error, + ), + ); + } + } + } + + String _friendlyError(Object e) { + final msg = e.toString(); + if (msg.contains('FormatException') || msg.contains('type')) { + return 'Invalid JSON format or missing required fields'; + } + return msg.length > 80 ? '${msg.substring(0, 80)}…' : msg; + } +} + +// ── Program Card ────────────────────────────────────────────────────────── + +class _ProgramCard extends StatelessWidget { + final TrainingProgram program; + + const _ProgramCard({required this.program}); + + @override + Widget build(BuildContext context) { + final deloadCount = program.weeks.where((w) => w.isDeload).length; + + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProgramDetailScreen(program: program), + ), + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: const Icon( + Icons.calendar_month, + color: AppTheme.primaryColor, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + program.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: AppTheme.textPrimary, + ), + ), + if (program.author != null) + Text( + program.author!, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textMuted, + ), + ), + ], + ), + ), + if (program.isImported) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + Icons.download_done, + size: 16, + color: AppTheme.textMuted, + ), + ), + const Icon( + Icons.chevron_right, + color: AppTheme.textMuted, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (program.description != null) ...[ + Text( + program.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + // Stats row + Row( + children: [ + _badge( + '${program.totalWeeks}w', + AppTheme.primaryColor, + ), + const SizedBox(width: AppSpacing.xs), + _badge( + '${program.phases.length} phases', + AppTheme.secondaryColor, + ), + if (deloadCount > 0) ...[ + const SizedBox(width: AppSpacing.xs), + _badge('$deloadCount deload', Colors.amber), + ], + ], + ), + if (program.phases.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + _buildMiniTimeline(), + ], + ], + ), + ), + ), + ); + } + + static const _phaseColors = [ + AppTheme.primaryColor, + AppTheme.secondaryColor, + Colors.orange, + Colors.pink, + Colors.green, + ]; + + Widget _buildMiniTimeline() { + return SizedBox( + height: 4, + child: Row( + children: program.phases.asMap().entries.map((entry) { + final phase = entry.value; + final color = _phaseColors[entry.key % _phaseColors.length]; + final fraction = + (phase.endWeek - phase.startWeek + 1) / program.totalWeeks; + return Expanded( + flex: ((fraction * 100).round()).clamp(1, 100), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _badge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 381a214..04288e0 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -1,4 +1,4 @@ -// Routines Screen - Manage workout routines +// Routines Screen - Manage workout routines and training programs import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,17 +8,46 @@ import '../services/workout_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; import 'workout_flow_screen.dart'; +import 'programs/programs_screen.dart'; class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Routines'), + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.list_alt), text: 'Routines'), + Tab(icon: Icon(Icons.calendar_month), text: 'Programs'), + ], + ), + ), + body: const TabBarView( + children: [ + _RoutinesTab(), + ProgramsScreen(), + ], + ), + ), + ); + } +} + +class _RoutinesTab extends StatelessWidget { + const _RoutinesTab(); + @override Widget build(BuildContext context) { final provider = context.watch(); final routines = provider.routines; return Scaffold( - appBar: AppBar(title: const Text('Routines')), + backgroundColor: AppTheme.backgroundColor, body: routines.isEmpty ? _buildEmptyState(context) : _buildRoutineList(context, routines, provider), diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index c8eb6fc..7ad6e1e 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -60,6 +60,13 @@ abstract class IStorageService { Future saveSetting(String key, String value); Future getSetting(String key); + // ==================== TRAINING PROGRAMS ==================== + + Future saveTrainingProgram(TrainingProgram program); + Future> getAllTrainingPrograms(); + Future getTrainingProgram(String id); + Future deleteTrainingProgram(String id); + // ==================== EXPORT / IMPORT ==================== Future exportAllData(); diff --git a/workout-logger/lib/services/managers/managers.dart b/workout-logger/lib/services/managers/managers.dart index ebf68c3..749c0d9 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -15,3 +15,4 @@ export 'routine_manager.dart'; export 'exercise_manager.dart'; export 'target_manager.dart'; export 'analytics_manager.dart'; +export 'program_manager.dart'; diff --git a/workout-logger/lib/services/managers/program_manager.dart b/workout-logger/lib/services/managers/program_manager.dart new file mode 100644 index 0000000..95cc8ce --- /dev/null +++ b/workout-logger/lib/services/managers/program_manager.dart @@ -0,0 +1,106 @@ +// Program Manager — SRP-focused manager for Training Programs +// +// Handles: loading, saving, deleting, importing, and exporting +// TrainingProgram entities. Follows the same manager pattern used +// by RoutineManager, TargetManager, etc. + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; +import '../../models/models.dart'; +import '../interfaces/storage_service_interface.dart'; + +class ProgramManager extends ChangeNotifier { + final IStorageService _storage; + final Uuid _uuid = const Uuid(); + + List _programs = []; + + List get programs => List.unmodifiable(_programs); + + ProgramManager(this._storage); + + // ==================== LOAD ==================== + + Future loadPrograms() async { + _programs = await _storage.getAllTrainingPrograms(); + notifyListeners(); + } + + // ==================== CRUD ==================== + + Future saveProgram(TrainingProgram program) async { + await _storage.saveTrainingProgram(program); + final idx = _programs.indexWhere((p) => p.id == program.id); + if (idx >= 0) { + _programs[idx] = program; + } else { + _programs.insert(0, program); + } + notifyListeners(); + } + + Future createProgram({ + required String name, + String? description, + String? author, + required int totalWeeks, + List? phases, + List? weeks, + }) async { + final program = TrainingProgram( + id: _uuid.v4(), + name: name, + description: description, + author: author, + totalWeeks: totalWeeks, + phases: phases ?? [], + weeks: weeks ?? [], + ); + await saveProgram(program); + return program; + } + + Future deleteProgram(String id) async { + await _storage.deleteTrainingProgram(id); + _programs.removeWhere((p) => p.id == id); + notifyListeners(); + } + + // ==================== IMPORT / EXPORT ==================== + + /// Import a program from a raw JSON string. + /// + /// The JSON must match the [TrainingProgram.toJson] schema. + /// A new UUID is assigned so imports never conflict with existing IDs. + /// Throws a [FormatException] if the JSON is malformed or missing required fields. + Future importFromJson(String jsonString) async { + final Map raw = + jsonDecode(jsonString) as Map; + + // Assign a new local ID and mark as imported + raw['id'] = _uuid.v4(); + raw['isImported'] = true; + raw['createdAt'] = DateTime.now().toIso8601String(); + + final program = TrainingProgram.fromJson(raw); + await saveProgram(program); + return program; + } + + /// Export a program as a pretty-printed JSON string. + String exportToJson(TrainingProgram program) { + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert(program.toJson()); + } + + // ==================== HELPERS ==================== + + TrainingProgram? getProgramById(String id) { + try { + return _programs.firstWhere((p) => p.id == id); + } catch (_) { + return null; + } + } +} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index dbf1ede..769ef07 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -22,6 +22,7 @@ class StorageService implements IStorageService { static const String _muscleGroupsBox = 'muscle_groups'; static const String _customExercisesBox = 'custom_exercises'; static const String _settingsBox = 'settings'; + static const String _trainingProgramsBox = 'training_programs'; late Box _sessionsBox; late Box _routinesBoxInstance; @@ -29,6 +30,7 @@ class StorageService implements IStorageService { late Box _muscleGroupsBoxInstance; late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; + late Box _trainingProgramsBoxInstance; bool _initialized = false; @@ -46,6 +48,9 @@ class StorageService implements IStorageService { _customExercisesBox, ); _settingsBoxInstance = await Hive.openBox(_settingsBox); + _trainingProgramsBoxInstance = await Hive.openBox( + _trainingProgramsBox, + ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -289,6 +294,38 @@ class StorageService implements IStorageService { } } + // ==================== TRAINING PROGRAMS ==================== + + @override + Future saveTrainingProgram(TrainingProgram program) async { + await _trainingProgramsBoxInstance.put( + program.id, + jsonEncode(program.toJson()), + ); + } + + @override + Future> getAllTrainingPrograms() async { + final programs = []; + for (final json in _trainingProgramsBoxInstance.values) { + programs.add(TrainingProgram.fromJson(jsonDecode(json))); + } + programs.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return programs; + } + + @override + Future getTrainingProgram(String id) async { + final json = _trainingProgramsBoxInstance.get(id); + if (json == null) return null; + return TrainingProgram.fromJson(jsonDecode(json)); + } + + @override + Future deleteTrainingProgram(String id) async { + await _trainingProgramsBoxInstance.delete(id); + } + // ==================== STATS ==================== Future> getQuickStats() async { diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 4929edd..6dc17f7 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -8,6 +8,7 @@ // - ExerciseManager: Exercise library // - TargetManager: Goals and targets // - AnalyticsManager: Statistics and recommendations +// - ProgramManager: Training programs (multi-week plans) // // Following Dependency Inversion Principle: this class now depends on // abstractions (IStorageService, IMLService) rather than concrete implementations. @@ -20,6 +21,7 @@ import 'interfaces/storage_service_interface.dart'; import 'interfaces/ml_service_interface.dart'; import 'ml_service.dart'; import 'strategies/target_calculator.dart'; +import 'managers/program_manager.dart'; class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; @@ -35,6 +37,8 @@ class WorkoutProvider extends ChangeNotifier { final Map _growthModels = {}; // exerciseId -> GrowthModel + late final ProgramManager programManager; + // Active workout state WorkoutSession? _activeSession; Routine? _activeRoutine; @@ -61,7 +65,9 @@ class WorkoutProvider extends ChangeNotifier { /// Following Dependency Inversion Principle: accepts abstractions /// rather than concrete implementations. WorkoutProvider(this._storage, {IMLService? mlService}) - : _mlService = mlService ?? MLService(); + : _mlService = mlService ?? MLService() { + programManager = ProgramManager(_storage); + } // ==================== INITIALIZATION ==================== @@ -77,6 +83,7 @@ class WorkoutProvider extends ChangeNotifier { _targets = await _storage.getAllTargets(); _muscleGroups = await _storage.getAllMuscleGroups(); _allExercises = await _storage.getAllExercises(); + await programManager.loadPrograms(); notifyListeners(); } diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index ac09b6b..a422ba5 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -19,6 +19,7 @@ class MockStorageService implements IStorageService { final List _targets = []; final List _muscleGroups = []; final Map _settings = {}; + final List _trainingPrograms = []; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; @@ -225,6 +226,34 @@ class MockStorageService implements IStorageService { @override Future getSetting(String key) async => _settings[key]; + @override + Future saveTrainingProgram(TrainingProgram program) async { + final index = _trainingPrograms.indexWhere((p) => p.id == program.id); + if (index >= 0) { + _trainingPrograms[index] = program; + } else { + _trainingPrograms.add(program); + } + } + + @override + Future> getAllTrainingPrograms() async => + List.from(_trainingPrograms); + + @override + Future getTrainingProgram(String id) async { + try { + return _trainingPrograms.firstWhere((p) => p.id == id); + } catch (_) { + return null; + } + } + + @override + Future deleteTrainingProgram(String id) async { + _trainingPrograms.removeWhere((p) => p.id == id); + } + @override Future exportAllData() async => '{}'; From d5d965fe1ea4d487711b5f417851e406af4bd5cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 06:30:13 +0000 Subject: [PATCH 04/11] feat: add example 12-week JSON and full-screen import UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit example_12_week_program.json (docs/): - 12 weeks · 3 phases (Foundation W1–4, Intensify W5–8, Peak W9–12) - 5 training days/week: Push (Mon), Core (Wed), Pull (Thu), Shoulders & Arms (Fri), Legs + Rehab (Sat) - 344 exercise slots across 60 days (~98 KB) - Deload weeks 5 (85% / −1 set) and 9 (90% / −1 set) - Supersets encoded with supersetGroupId per phase: ss-push-a (tricep pushdown + lateral raise, W5+) ss-pull-a (preacher curl + hammer curl, W5+) ss-shld-a (lateral raise + rear delt fly, W5+) ss-arm-a (bicep curl + tricep pushdown, W5+) - All exercise IDs match built-in ExerciseDatabase entries - Tempo notation on all compound lifts (e.g. "3-1-1") ImportProgramScreen (screens/programs/import_program_screen.dart): - Full-screen page replaces the cramped 8-line bottom sheet - Expands text area fills available viewport height (maxLines: null) - Live char / line / KB counter in status bar - Two-step flow: Validate → Import (button disabled until valid) - Inline field-level error messages (missing key, wrong type) - Green border + "Valid JSON" indicator on success - Red border + inline error on failure - Clear (×) button in AppBar ProgramsScreen: - _showImportDialog / _doImport / _friendlyError removed - Replaced with _openImport → Navigator.push to ImportProgramScreen - Success snackbar shown on return with result == true https://claude.ai/code/session_01E15CGTZZDU8rz7PX1nYXA3 --- docs/example_12_week_program.json | 3473 +++++++++++++++++ .../programs/import_program_screen.dart | 355 ++ .../lib/screens/programs/programs_screen.dart | 115 +- 3 files changed, 3838 insertions(+), 105 deletions(-) create mode 100644 docs/example_12_week_program.json create mode 100644 workout-logger/lib/screens/programs/import_program_screen.dart diff --git a/docs/example_12_week_program.json b/docs/example_12_week_program.json new file mode 100644 index 0000000..59b7215 --- /dev/null +++ b/docs/example_12_week_program.json @@ -0,0 +1,3473 @@ +{ + "id": "12-week-hypertrophy-2026", + "name": "12-Week Hypertrophy Program", + "description": "Linear periodisation with undulating intensity. Three phases — Foundation (W1–4), Intensify (W5–8), Peak (W9–12). Prioritises chest, shoulders and arms (3× frequency). Knee-friendly leg work substitutes squats/lunges. Deload weeks: W5 (−15% weight) and W9 (−10% weight).", + "totalWeeks": 12, + "author": "RepForge Example", + "isImported": false, + "createdAt": "2026-01-01T00:00:00.000", + "phases": [ + { + "id": "phase-foundation", + "name": "Foundation", + "startWeek": 1, + "endWeek": 4, + "notes": "Form mastery, mind-muscle connection. RPE 7. 3×10, 75–90 s rest.", + "colorHex": null + }, + { + "id": "phase-intensify", + "name": "Intensify", + "startWeek": 5, + "endWeek": 8, + "notes": "4th set added. Supersets introduced on arm/shoulder days. RPE 8. 60–75 s rest.", + "colorHex": null + }, + { + "id": "phase-peak", + "name": "Peak", + "startWeek": 9, + "endWeek": 12, + "notes": "Higher reps (12–15), shorter rest (45–60 s), tri-sets. RPE 8–9.", + "colorHex": null + } + ], + "weeks": [ + { + "weekNumber": 1, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-foundation", + "days": [ + { + "id": "w1-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Primary chest builder" + }, + { + "exerciseId": "bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1" + } + ] + }, + { + "id": "w1-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 30–45 s; aim for 60 s by week 4" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w1-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — stop 1-2 reps short of failure" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric phase" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Non-negotiable — rotator cuff health" + } + ] + }, + { + "id": "w1-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "3-sec eccentric; seated to reduce cheating" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Upright-row substitute; keep light" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 60, + "tempo": "3-1-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + } + ] + }, + { + "id": "w1-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90, + "notes": "High foot placement — glute/hamstring dominant" + }, + { + "exerciseId": "leg_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1", + "notes": "3-sec eccentric — knee rehab focus" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Light weight only — stop if knee pain" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Back extension substitute; lower-back + glutes" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 2, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-foundation", + "days": [ + { + "id": "w2-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Primary chest builder" + }, + { + "exerciseId": "bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1" + } + ] + }, + { + "id": "w2-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 30–45 s; aim for 60 s by week 4" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w2-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — stop 1-2 reps short of failure" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric phase" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Non-negotiable — rotator cuff health" + } + ] + }, + { + "id": "w2-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "3-sec eccentric; seated to reduce cheating" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Upright-row substitute; keep light" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 60, + "tempo": "3-1-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + } + ] + }, + { + "id": "w2-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90, + "notes": "High foot placement — glute/hamstring dominant" + }, + { + "exerciseId": "leg_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1", + "notes": "3-sec eccentric — knee rehab focus" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Light weight only — stop if knee pain" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Back extension substitute; lower-back + glutes" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 3, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-foundation", + "days": [ + { + "id": "w3-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Primary chest builder" + }, + { + "exerciseId": "bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1" + } + ] + }, + { + "id": "w3-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 30–45 s; aim for 60 s by week 4" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w3-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — stop 1-2 reps short of failure" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric phase" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Non-negotiable — rotator cuff health" + } + ] + }, + { + "id": "w3-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "3-sec eccentric; seated to reduce cheating" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Upright-row substitute; keep light" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 60, + "tempo": "3-1-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + } + ] + }, + { + "id": "w3-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90, + "notes": "High foot placement — glute/hamstring dominant" + }, + { + "exerciseId": "leg_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1", + "notes": "3-sec eccentric — knee rehab focus" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Light weight only — stop if knee pain" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Back extension substitute; lower-back + glutes" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 4, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-foundation", + "days": [ + { + "id": "w4-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Primary chest builder" + }, + { + "exerciseId": "bench_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1" + } + ] + }, + { + "id": "w4-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 30–45 s; aim for 60 s by week 4" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w4-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — stop 1-2 reps short of failure" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric phase" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Non-negotiable — rotator cuff health" + } + ] + }, + { + "id": "w4-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "3-sec eccentric; seated to reduce cheating" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Upright-row substitute; keep light" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 60, + "tempo": "3-1-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + } + ] + }, + { + "id": "w4-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90, + "notes": "High foot placement — glute/hamstring dominant" + }, + { + "exerciseId": "leg_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1", + "notes": "3-sec eccentric — knee rehab focus" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Light weight only — stop if knee pain" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Back extension substitute; lower-back + glutes" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 5, + "isDeload": true, + "deloadIntensityFactor": 0.85, + "deloadSetReduction": 1, + "phaseId": "phase-intensify", + "days": [ + { + "id": "w5-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 8, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Add weight if all reps complete" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1", + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after the pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w5-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 45–60 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w5-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — track reps for progression" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w5-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "Seated; 3-sec eccentric", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "notes": "Superset for max arm pump — rest 45s after pair", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w5-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 90, + "notes": "High foot placement" + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Rehab stimulus only" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Add 5 kg vs Phase 1 if form allows" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": "Deload week — 85% weight, −1 set per exercise. Focus on form." + }, + { + "weekNumber": 6, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-intensify", + "days": [ + { + "id": "w6-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 8, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Add weight if all reps complete" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1", + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after the pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w6-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 45–60 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w6-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — track reps for progression" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w6-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "Seated; 3-sec eccentric", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "notes": "Superset for max arm pump — rest 45s after pair", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w6-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 90, + "notes": "High foot placement" + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Rehab stimulus only" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Add 5 kg vs Phase 1 if form allows" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 7, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-intensify", + "days": [ + { + "id": "w7-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 8, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Add weight if all reps complete" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1", + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after the pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w7-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 45–60 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w7-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — track reps for progression" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w7-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "Seated; 3-sec eccentric", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "notes": "Superset for max arm pump — rest 45s after pair", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w7-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 90, + "notes": "High foot placement" + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Rehab stimulus only" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Add 5 kg vs Phase 1 if form allows" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 8, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-intensify", + "days": [ + { + "id": "w8-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 8, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Add weight if all reps complete" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-0-1" + }, + { + "exerciseId": "pec_deck", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-0-1" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-1", + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after the pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w8-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 45–60 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w8-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 6, + "maxReps": 12, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — track reps for progression" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "3-1-1", + "notes": "3-sec eccentric", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w8-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-1" + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "notes": "Seated; 3-sec eccentric", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "notes": "Superset for max arm pump — rest 45s after pair", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w8-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 90, + "notes": "High foot placement" + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Rehab stimulus only" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Add 5 kg vs Phase 1 if form allows" + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 9, + "isDeload": true, + "deloadIntensityFactor": 0.9, + "deloadSetReduction": 1, + "phaseId": "phase-peak", + "days": [ + { + "id": "w9-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Target: +2.5 kg vs Phase 2" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75 + }, + { + "exerciseId": "pec_deck", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w9-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 60–75 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w9-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 8, + "maxReps": 15, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — target +3 reps vs week 1" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "3-1-1", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w9-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90 + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Superset — rest 45s after pair; 21s curl added W9+", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w9-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90 + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 60, + "notes": "Rehab stimulus — light weight" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": "Mini-deload — 90% weight, −1 set. Prep for peak block." + }, + { + "weekNumber": 10, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-peak", + "days": [ + { + "id": "w10-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Target: +2.5 kg vs Phase 2" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75 + }, + { + "exerciseId": "pec_deck", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w10-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 60–75 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w10-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 8, + "maxReps": 15, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — target +3 reps vs week 1" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "3-1-1", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w10-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90 + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Superset — rest 45s after pair; 21s curl added W9+", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w10-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90 + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 60, + "notes": "Rehab stimulus — light weight" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 11, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-peak", + "days": [ + { + "id": "w11-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Target: +2.5 kg vs Phase 2" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75 + }, + { + "exerciseId": "pec_deck", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w11-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 60–75 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w11-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 8, + "maxReps": 15, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — target +3 reps vs week 1" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "3-1-1", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w11-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90 + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Superset — rest 45s after pair; 21s curl added W9+", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w11-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90 + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 60, + "notes": "Rehab stimulus — light weight" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + }, + { + "weekNumber": 12, + "isDeload": false, + "deloadIntensityFactor": 1.0, + "deloadSetReduction": 0, + "phaseId": "phase-peak", + "days": [ + { + "id": "w12-push", + "name": "Push", + "dayOfWeek": 1, + "notes": "Chest · Triceps · Front Delts", + "exercises": [ + { + "exerciseId": "incline_bench_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "3-1-1", + "notes": "Target: +2.5 kg vs Phase 2" + }, + { + "exerciseId": "bench_press", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75 + }, + { + "exerciseId": "pec_deck", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "overhead_tricep_extension", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "supersetGroupId": "ss-push-a" + }, + { + "exerciseId": "lateral_raise", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2", + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-push-a" + } + ] + }, + { + "id": "w12-core", + "name": "Core", + "dayOfWeek": 3, + "notes": "Abs · Anti-rotation · Lower back stability", + "exercises": [ + { + "exerciseId": "plank", + "sets": 3, + "minReps": 30, + "maxReps": 45, + "restSeconds": 45, + "notes": "Hold 60–75 s" + }, + { + "exerciseId": "leg_raises", + "sets": 3, + "minReps": 10, + "maxReps": 10, + "restSeconds": 45, + "notes": "Dead bug substitute — lower back flat" + }, + { + "exerciseId": "russian_twist", + "sets": 3, + "minReps": 12, + "maxReps": 12, + "restSeconds": 45, + "tempo": "2-1-2", + "notes": "Pallof press substitute — anti-rotation" + }, + { + "exerciseId": "cable_crunch", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Rope attachment, kneel" + }, + { + "exerciseId": "crunches", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 45, + "notes": "Hollow body hold substitute" + } + ] + }, + { + "id": "w12-pull", + "name": "Pull", + "dayOfWeek": 4, + "notes": "Back · Biceps · Rear Delts", + "exercises": [ + { + "exerciseId": "seated_cable_row", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90, + "tempo": "2-1-2" + }, + { + "exerciseId": "lat_pulldown", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 75, + "tempo": "2-1-2" + }, + { + "exerciseId": "pull_ups", + "sets": 3, + "minReps": 8, + "maxReps": 15, + "restSeconds": 90, + "tempo": "2-0-1", + "notes": "AMRAP — target +3 reps vs week 1" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + }, + { + "exerciseId": "preacher_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "3-1-1", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "hammer_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-pull-a" + }, + { + "exerciseId": "face_pull", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-2" + } + ] + }, + { + "id": "w12-shld-arms", + "name": "Shoulders & Arms", + "dayOfWeek": 5, + "notes": "All delt heads · Biceps · Triceps", + "exercises": [ + { + "exerciseId": "dumbbell_shoulder_press", + "sets": 4, + "minReps": 10, + "maxReps": 10, + "restSeconds": 90 + }, + { + "exerciseId": "lateral_raise", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "tempo": "2-1-3", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "rear_delt_fly", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60, + "notes": "Superset — rest 60s after pair", + "supersetGroupId": "ss-shld-a" + }, + { + "exerciseId": "front_raise", + "sets": 4, + "minReps": 12, + "maxReps": 12, + "restSeconds": 60 + }, + { + "exerciseId": "bicep_curl", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "tempo": "3-1-1", + "supersetGroupId": "ss-arm-a" + }, + { + "exerciseId": "tricep_pushdown", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 45, + "notes": "Superset — rest 45s after pair; 21s curl added W9+", + "supersetGroupId": "ss-arm-a" + } + ] + }, + { + "id": "w12-legs", + "name": "Legs + Rehab", + "dayOfWeek": 6, + "notes": "Quads · Hamstrings · Glutes · Knee rehab protocol after session", + "exercises": [ + { + "exerciseId": "leg_press", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 90 + }, + { + "exerciseId": "leg_curl", + "sets": 4, + "minReps": 15, + "maxReps": 15, + "restSeconds": 75, + "tempo": "3-0-1" + }, + { + "exerciseId": "leg_extension", + "sets": 3, + "minReps": 20, + "maxReps": 20, + "restSeconds": 60, + "notes": "Rehab stimulus — light weight" + }, + { + "exerciseId": "romanian_deadlift", + "sets": 3, + "minReps": 15, + "maxReps": 15, + "restSeconds": 60 + }, + { + "exerciseId": "calf_raise", + "sets": 2, + "minReps": 25, + "maxReps": 25, + "restSeconds": 45 + } + ] + } + ], + "notes": null + } + ] +} \ No newline at end of file diff --git a/workout-logger/lib/screens/programs/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart new file mode 100644 index 0000000..3e3bcfe --- /dev/null +++ b/workout-logger/lib/screens/programs/import_program_screen.dart @@ -0,0 +1,355 @@ +// Import Program Screen +// +// Full-screen editor for pasting or typing a TrainingProgram JSON. +// Replaces the cramped bottom-sheet approach so large programs +// (e.g. a 12-week plan at ~100 KB) can be pasted comfortably. +// +// Features: +// • Expandable text area that fills the screen +// • Live character / line counter +// • "Validate JSON" step before committing to storage +// • Actionable error messages (missing field, bad type, etc.) +// • Clear button to wipe the field + +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +class ImportProgramScreen extends StatefulWidget { + const ImportProgramScreen({super.key}); + + @override + State createState() => _ImportProgramScreenState(); +} + +class _ImportProgramScreenState extends State { + final _ctrl = TextEditingController(); + final _scrollCtrl = ScrollController(); + + _ValidationState _validationState = _ValidationState.idle; + String? _validationError; + Map? _parsed; // non-null = validated ok + + @override + void dispose() { + _ctrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + // ── Build ──────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.backgroundColor, + appBar: AppBar( + title: const Text('Import Program'), + actions: [ + if (_ctrl.text.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear', + onPressed: _clearField, + ), + ], + ), + body: Column( + children: [ + _buildInstructions(), + Expanded(child: _buildTextField()), + _buildStatusBar(), + _buildActionBar(), + ], + ), + ); + } + + // ── Instructions ───────────────────────────────────────────────────── + + Widget _buildInstructions() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + color: AppTheme.surfaceColor, + child: Text( + 'Paste a TrainingProgram JSON. ' + 'Required fields: name · totalWeeks · phases · weeks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + } + + // ── Text field ──────────────────────────────────────────────────────── + + Widget _buildTextField() { + return Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: TextField( + controller: _ctrl, + scrollController: _scrollCtrl, + maxLines: null, // expands to fill available height + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textPrimary, + height: 1.5, + ), + decoration: InputDecoration( + hintText: '{\n "name": "My Program",\n "totalWeeks": 12,\n ...\n}', + hintStyle: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: AppTheme.textMuted, + ), + filled: true, + fillColor: AppTheme.surfaceColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide( + color: _borderColor, + width: _validationState != _ValidationState.idle ? 1.5 : 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: _borderColor, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + borderSide: BorderSide(color: _borderColor, width: 2), + ), + contentPadding: const EdgeInsets.all(AppSpacing.md), + ), + onChanged: (_) { + // Reset validation when user edits + if (_validationState != _ValidationState.idle) { + setState(() { + _validationState = _ValidationState.idle; + _validationError = null; + _parsed = null; + }); + } else { + setState(() {}); // refresh char counter + } + }, + ), + ); + } + + Color get _borderColor { + switch (_validationState) { + case _ValidationState.valid: + return AppTheme.success; + case _ValidationState.invalid: + return AppTheme.error; + case _ValidationState.idle: + return AppTheme.surfaceColor; + } + } + + // ── Status bar (char / line count + validation message) ────────────── + + Widget _buildStatusBar() { + final text = _ctrl.text; + final chars = text.length; + final lines = text.isEmpty ? 0 : '\n'.allMatches(text).length + 1; + final kb = (text.length / 1024).toStringAsFixed(1); + + return Container( + color: AppTheme.surfaceColor, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Text( + '$chars chars · $lines lines · $kb KB', + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + fontFamily: 'monospace', + ), + ), + const Spacer(), + if (_validationState == _ValidationState.valid) + const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, size: 14, color: AppTheme.success), + SizedBox(width: 4), + Text( + 'Valid JSON', + style: TextStyle( + fontSize: 11, + color: AppTheme.success, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (_validationState == _ValidationState.invalid && + _validationError != null) + Expanded( + child: Text( + _validationError!, + style: const TextStyle(fontSize: 11, color: AppTheme.error), + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + // ── Action bar ──────────────────────────────────────────────────────── + + Widget _buildActionBar() { + final canValidate = _ctrl.text.trim().isNotEmpty && + _validationState != _ValidationState.valid; + final canImport = _validationState == _ValidationState.valid && _parsed != null; + + return SafeArea( + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: const BoxDecoration( + color: AppTheme.cardColor, + border: Border(top: BorderSide(color: AppTheme.surfaceColor, width: 1)), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: canValidate ? _validate : null, + child: const Text('Validate'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ElevatedButton( + onPressed: canImport ? _import : null, + style: canImport + ? null + : ElevatedButton.styleFrom( + backgroundColor: AppTheme.surfaceColor, + ), + child: const Text('Import'), + ), + ), + ], + ), + ), + ); + } + + // ── Logic ───────────────────────────────────────────────────────────── + + void _clearField() { + _ctrl.clear(); + setState(() { + _validationState = _ValidationState.idle; + _validationError = null; + _parsed = null; + }); + } + + void _validate() { + final text = _ctrl.text.trim(); + if (text.isEmpty) return; + + try { + final decoded = jsonDecode(text); + if (decoded is! Map) { + throw const FormatException('Top-level value must be a JSON object {}'); + } + + // Required field checks + _requireField(decoded, 'name', String); + _requireField(decoded, 'totalWeeks', int); + _requireField(decoded, 'phases', List); + _requireField(decoded, 'weeks', List); + + // Light structural check on first week + if ((decoded['weeks'] as List).isNotEmpty) { + final w = (decoded['weeks'] as List).first as Map; + _requireField(w, 'weekNumber', int); + _requireField(w, 'days', List); + } + + setState(() { + _validationState = _ValidationState.valid; + _validationError = null; + _parsed = decoded; + }); + } on FormatException catch (e) { + setState(() { + _validationState = _ValidationState.invalid; + _validationError = e.message; + _parsed = null; + }); + } catch (e) { + setState(() { + _validationState = _ValidationState.invalid; + _validationError = e.toString().replaceFirst('Exception: ', ''); + _parsed = null; + }); + } + } + + void _requireField(Map map, String key, Type type) { + if (!map.containsKey(key)) { + throw FormatException('Missing required field: "$key"'); + } + if (type == int && map[key] is! int) { + throw FormatException('"$key" must be an integer, got ${map[key].runtimeType}'); + } + if (type == String && map[key] is! String) { + throw FormatException('"$key" must be a string, got ${map[key].runtimeType}'); + } + if (type == List && map[key] is! List) { + throw FormatException('"$key" must be an array, got ${map[key].runtimeType}'); + } + } + + Future _import() async { + if (_parsed == null) return; + + try { + final provider = context.read(); + // Re-encode the validated parsed map to pass through importFromJson + final json = jsonEncode(_parsed); + await provider.programManager.importFromJson(json); + + if (mounted) { + Navigator.pop(context, true); // signal success to caller + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Import failed: ${_shortError(e)}'), + backgroundColor: AppTheme.error, + ), + ); + } + } + + String _shortError(Object e) { + final s = e.toString().replaceFirst('Exception: ', ''); + return s.length > 120 ? '${s.substring(0, 120)}…' : s; + } +} + +enum _ValidationState { idle, valid, invalid } diff --git a/workout-logger/lib/screens/programs/programs_screen.dart b/workout-logger/lib/screens/programs/programs_screen.dart index 5ea1300..328bab1 100644 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -3,7 +3,7 @@ // Shows the list of training programs and provides entry points for: // - Viewing program details // - Creating a new program -// - Importing a program from JSON (paste dialog) +// - Importing a program from JSON (full-screen ImportProgramScreen) import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -13,6 +13,7 @@ import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; import 'program_detail_screen.dart'; import 'program_designer_screen.dart'; +import 'import_program_screen.dart'; class ProgramsScreen extends StatelessWidget { const ProgramsScreen({super.key}); @@ -36,7 +37,7 @@ class ProgramsScreen extends StatelessWidget { children: [ FloatingActionButton.small( heroTag: 'import_json', - onPressed: () => _showImportDialog(context), + onPressed: () => _openImport(context), backgroundColor: AppTheme.surfaceColor, child: const Icon(Icons.download, color: AppTheme.secondaryColor), ), @@ -87,7 +88,7 @@ class ProgramsScreen extends StatelessWidget { ), const SizedBox(width: AppSpacing.md), OutlinedButton.icon( - onPressed: () => _showImportDialog(context), + onPressed: () => _openImport(context), icon: const Icon(Icons.download), label: const Text('Import JSON'), ), @@ -125,112 +126,16 @@ class ProgramsScreen extends StatelessWidget { ); } - void _showImportDialog(BuildContext context) { - final ctrl = TextEditingController(); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppTheme.cardColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => Padding( - padding: EdgeInsets.only( - left: AppSpacing.lg, - right: AppSpacing.lg, - top: AppSpacing.lg, - bottom: MediaQuery.of(context).viewInsets.bottom + AppSpacing.lg, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Import Program from JSON', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Paste a valid TrainingProgram JSON below.\n' - 'The program must include: name, totalWeeks, phases, weeks.', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - TextField( - controller: ctrl, - maxLines: 8, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - ), - decoration: const InputDecoration( - hintText: '{ "name": "...", "totalWeeks": 12, ... }', - border: OutlineInputBorder(), - isDense: true, - ), - ), - const SizedBox(height: AppSpacing.md), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - const SizedBox(width: AppSpacing.sm), - ElevatedButton.icon( - onPressed: () => _doImport(context, ctrl.text), - icon: const Icon(Icons.download), - label: const Text('Import'), - ), - ], - ), - ], - ), - ), + Future _openImport(BuildContext context) async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ImportProgramScreen()), ); - } - - Future _doImport(BuildContext context, String json) async { - if (json.trim().isEmpty) { + if (result == true && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please paste JSON before importing')), + const SnackBar(content: Text('Program imported successfully!')), ); - return; - } - - try { - final provider = context.read(); - await provider.programManager.importFromJson(json.trim()); - if (context.mounted) { - Navigator.pop(context); // close bottom sheet - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Program imported successfully!')), - ); - } - } catch (e) { - if (context.mounted) { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Import failed: ${_friendlyError(e)}'), - backgroundColor: AppTheme.error, - ), - ); - } - } - } - - String _friendlyError(Object e) { - final msg = e.toString(); - if (msg.contains('FormatException') || msg.contains('type')) { - return 'Invalid JSON format or missing required fields'; } - return msg.length > 80 ? '${msg.substring(0, 80)}…' : msg; } } From 61ecc5b1cc52eaf001947f18942ae151cad5f7e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 06:31:21 +0000 Subject: [PATCH 05/11] feat: add file picker to ImportProgramScreen file_picker (already in pubspec) is now wired into the import screen: - Folder icon in AppBar opens native file picker filtered to .json - Selected file content is read and loaded into the text field - Validation / Import flow unchanged after loading Instructions banner updated to mention both input methods. https://claude.ai/code/session_01E15CGTZZDU8rz7PX1nYXA3 --- .../programs/import_program_screen.dart | 58 +++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/workout-logger/lib/screens/programs/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart index 3e3bcfe..08938ed 100644 --- a/workout-logger/lib/screens/programs/import_program_screen.dart +++ b/workout-logger/lib/screens/programs/import_program_screen.dart @@ -1,17 +1,20 @@ // Import Program Screen // -// Full-screen editor for pasting or typing a TrainingProgram JSON. -// Replaces the cramped bottom-sheet approach so large programs -// (e.g. a 12-week plan at ~100 KB) can be pasted comfortably. +// Full-screen editor for loading a TrainingProgram JSON. +// Supports two input methods: +// 1. Pick a .json file from device storage (file_picker) +// 2. Paste raw JSON directly into the text field // -// Features: +// Other features: // • Expandable text area that fills the screen -// • Live character / line counter -// • "Validate JSON" step before committing to storage -// • Actionable error messages (missing field, bad type, etc.) -// • Clear button to wipe the field +// • Live character / line / KB counter +// • "Validate" step before committing — shows field-level errors +// • Import button disabled until JSON is validated +// • Clear (×) button in AppBar import 'dart:convert'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -49,6 +52,11 @@ class _ImportProgramScreenState extends State { appBar: AppBar( title: const Text('Import Program'), actions: [ + IconButton( + icon: const Icon(Icons.folder_open), + tooltip: 'Pick JSON file', + onPressed: _pickFile, + ), if (_ctrl.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear), @@ -79,8 +87,8 @@ class _ImportProgramScreenState extends State { ), color: AppTheme.surfaceColor, child: Text( - 'Paste a TrainingProgram JSON. ' - 'Required fields: name · totalWeeks · phases · weeks.', + 'Tap to pick a .json file, or paste JSON below. ' + 'Required: name · totalWeeks · phases · weeks.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), @@ -256,6 +264,36 @@ class _ImportProgramScreenState extends State { // ── Logic ───────────────────────────────────────────────────────────── + Future _pickFile() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + + final path = result.files.single.path; + if (path == null) return; + + final content = await File(path).readAsString(); + _ctrl.text = content; + setState(() { + _validationState = _ValidationState.idle; + _validationError = null; + _parsed = null; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not read file: ${e.toString()}'), + backgroundColor: AppTheme.error, + ), + ); + } + } + void _clearField() { _ctrl.clear(); setState(() { From 30553189283dd2645cb87dae9cfc032ca7910f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:57:50 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20make=20programs=20actionable=20?= =?UTF-8?q?=E2=80=94=20program-aware=20workout=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkoutFlowScreen: add programDay/programWeek optional params so any day in a program can be started directly from the detail screen - Slot-based rest time: rest seconds are pulled from ProgramExerciseSlot and applied automatically when completing each set - Superset auto-advance: completing a set advances immediately to the next exercise (no rest timer) when both share the same supersetGroupId - Program meta banner: shown above the SET DONE button during program-mode workouts; displays target sets×rep-range, rest duration, tempo, 1RM %, deload indicator, superset label, and slot notes - Pull-up/chin-up assist mode: weight input label changes to 'Assist kg (0=BW)' for pull_ups and chin_ups exercise IDs - ProgramDetailScreen: add 'Start ' ElevatedButton at the bottom of each expanded day section; navigates to WorkoutFlowScreen with the correct programDay and programWeek context https://claude.ai/code/session_01E15CGTZZDU8rz7PX1nYXA3 --- .../programs/program_detail_screen.dart | 23 +++ .../lib/screens/workout_flow_screen.dart | 179 +++++++++++++++++- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index 2d62f49..c48c570 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -11,6 +11,7 @@ import 'dart:ui' show FontFeature; import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; +import '../workout_flow_screen.dart'; class ProgramDetailScreen extends StatefulWidget { final TrainingProgram program; @@ -515,6 +516,28 @@ class _ProgramDetailScreenState extends State { .toList(), ); }), + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WorkoutFlowScreen( + programDay: day, + programWeek: week, + ), + ), + ), + icon: const Icon(Icons.play_arrow, size: 18), + label: Text('Start ${day.name}'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), ], ), ); diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index a2bcc06..852de53 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -13,8 +13,16 @@ import 'exercise_library_screen.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; final bool isQuickStart; - - const WorkoutFlowScreen({super.key, this.routine, this.isQuickStart = false}); + final ProgramDay? programDay; + final ProgramWeek? programWeek; + + const WorkoutFlowScreen({ + super.key, + this.routine, + this.isQuickStart = false, + this.programDay, + this.programWeek, + }); @override State createState() => _WorkoutFlowScreenState(); @@ -49,10 +57,24 @@ class _WorkoutFlowScreenState extends State { }); } + ProgramExerciseSlot? _slotForIndex(int idx) { + if (widget.programDay == null) return null; + final slots = widget.programDay!.exercises; + return idx < slots.length ? slots[idx] : null; + } + void _initializeWorkout() { final provider = context.read(); - if (widget.routine != null) { + if (widget.programDay != null) { + final exerciseIds = + widget.programDay!.exercises.map((s) => s.exerciseId).toList(); + provider.startWorkout(exerciseIds: exerciseIds); + // Set initial rest time from first slot + final firstSlot = _slotForIndex(0); + if (firstSlot != null) _restSeconds = firstSlot.restSeconds; + _loadLastSessionData(); + } else if (widget.routine != null) { provider.startWorkout(routine: widget.routine); _loadLastSessionData(); } else if (widget.isQuickStart) { @@ -160,6 +182,9 @@ class _WorkoutFlowScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Program metadata banner (shown only in program-mode) + _buildProgramMetaBanner(provider), + // Recommendation card if (recommendations.isNotEmpty && currentLog != null) _buildRecommendationCard( @@ -170,7 +195,7 @@ class _WorkoutFlowScreenState extends State { const SizedBox(height: AppSpacing.lg), // Weight and reps input - if (!_isDropset) _buildInputSection(), + if (!_isDropset) _buildInputSection(provider), if (!_isDropset) const SizedBox(height: AppSpacing.md), @@ -345,13 +370,19 @@ class _WorkoutFlowScreenState extends State { ); } - Widget _buildInputSection() { + Widget _buildInputSection(WorkoutProvider provider) { + final exerciseId = provider.currentExercise?.id ?? ''; + final isAssistedBodyweight = + exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; + final weightLabel = + isAssistedBodyweight ? 'Assist kg (0=BW)' : 'Weight (kg)'; + return Row( children: [ // Weight input Expanded( child: _buildNumberInput( - label: 'Weight (kg)', + label: weightLabel, value: _currentWeight, onChanged: (val) => setState(() => _currentWeight = val), step: _currentWeight < 40 ? 2.5 : 5, @@ -927,6 +958,118 @@ class _WorkoutFlowScreenState extends State { ); } + // ==================== Program Meta Banner ==================== + + Widget _buildProgramMetaBanner(WorkoutProvider provider) { + if (widget.programDay == null || widget.programWeek == null) { + return const SizedBox.shrink(); + } + final slot = _slotForIndex(provider.currentExerciseIndex); + if (slot == null) return const SizedBox.shrink(); + + final week = widget.programWeek!; + final displaySets = week.isDeload + ? (slot.sets - week.deloadSetReduction).clamp(1, 99) + : slot.sets; + final repRange = slot.minReps == slot.maxReps + ? '${slot.minReps} reps' + : '${slot.minReps}–${slot.maxReps} reps'; + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppTheme.cardColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: week.isDeload + ? Colors.amber.withOpacity(0.4) + : AppTheme.primaryColor.withOpacity(0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (week.isDeload) ...[ + const Icon( + Icons.battery_charging_full, + size: 14, + color: Colors.amber, + ), + const SizedBox(width: 4), + const Text( + 'DELOAD ', + style: TextStyle( + fontSize: 11, + color: Colors.amber, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), + ), + ], + Text( + 'Target: $displaySets × $repRange', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: AppSpacing.md, + runSpacing: 4, + children: [ + _programChip( + Icons.timer_outlined, + '${slot.restSeconds}s rest', + AppTheme.textSecondary, + ), + if (slot.tempo != null) + _programChip(Icons.speed, 'Tempo ${slot.tempo}', AppTheme.secondaryColor), + if (slot.weightPercentage != null) + _programChip( + Icons.fitness_center, + week.isDeload + ? '${(slot.weightPercentage! * week.deloadIntensityFactor).toStringAsFixed(0)}% 1RM' + : '${slot.weightPercentage!.toStringAsFixed(0)}% 1RM', + AppTheme.primaryColor, + ), + if (slot.supersetGroupId != null) + _programChip(Icons.link, 'Superset', AppTheme.secondaryColor), + ], + ), + if (slot.notes != null) ...[ + const SizedBox(height: 4), + Text( + slot.notes!, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textMuted, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ); + } + + Widget _programChip(IconData icon, String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: color), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 11, color: color)), + ], + ); + } + // ==================== Rest Timer View ==================== Widget _buildRestTimerView() { @@ -1027,6 +1170,9 @@ class _WorkoutFlowScreenState extends State { void _completeSet() { final provider = context.read(); + final currentIdx = provider.currentExerciseIndex; + final currentSlot = _slotForIndex(currentIdx); + final nextSlot = _slotForIndex(currentIdx + 1); final set = WorkoutSet( weight: _currentWeight, @@ -1038,6 +1184,11 @@ class _WorkoutFlowScreenState extends State { provider.addSet(set); HapticFeedback.heavyImpact(); + // Update rest time from current slot + if (currentSlot != null) { + _restSeconds = currentSlot.restSeconds; + } + // Reset dropset state and dispose controllers to prevent memory leaks setState(() { _isDropset = false; @@ -1053,8 +1204,20 @@ class _WorkoutFlowScreenState extends State { _drops.clear(); }); - // Start rest timer - _startRestTimer(); + // Superset auto-advance: if next exercise is in the same superset group, + // advance immediately without a rest timer + final isSupersetPair = currentSlot?.supersetGroupId != null && + nextSlot?.supersetGroupId == currentSlot?.supersetGroupId; + + if (isSupersetPair) { + provider.nextExercise(); + _loadLastSessionData(); + // Apply the next slot's rest time so the subsequent rest is correct + final newSlot = _slotForIndex(provider.currentExerciseIndex); + if (newSlot != null) setState(() => _restSeconds = newSlot.restSeconds); + } else { + _startRestTimer(); + } } void _startRestTimer() { From 28d063d76ab65df88514465d9e15a529a4c58562 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:39:26 +0000 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20complete=20superset=20cycling=20?= =?UTF-8?q?=E2=80=94=20auto-return=20to=20group=20start=20after=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersets now follow the correct E1→E2 (no rest)→rest→E1→E2 cycle: - Add WorkoutProvider.goToExercise(index) for direct index navigation - Track _supersetReturnIndex: set when completing the last exercise in a superset group if the group still has unfinished sets - _skipRest() now navigates back to the superset group start when _supersetReturnIndex is set, restoring the correct exercise and rest time - Add _supersetGroupStart() to scan backward and find the group's first index - Add _supersetNeedsMoreSets() to check deload-adjusted target sets vs logged Flow: set1(E1) → advance to E2 → set1(E2) → rest → return to E1 → set2(E1) → advance to E2 → set2(E2) → rest → continue to E3 https://claude.ai/code/session_01E15CGTZZDU8rz7PX1nYXA3 --- .../lib/screens/workout_flow_screen.dart | 58 +++++++++++++++++++ .../lib/services/workout_provider.dart | 8 +++ 2 files changed, 66 insertions(+) diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 852de53..0ba402b 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -35,6 +35,9 @@ class _WorkoutFlowScreenState extends State { Timer? _restTimer; int _remainingSeconds = 0; + // Superset cycling: index to return to after rest (null = no return) + int? _supersetReturnIndex; + // Input controllers double _currentWeight = 20; int _currentReps = 10; @@ -63,6 +66,40 @@ class _WorkoutFlowScreenState extends State { return idx < slots.length ? slots[idx] : null; } + /// Finds the index of the first exercise in the same superset group, scanning + /// backward from [fromIdx]. + int _supersetGroupStart(int fromIdx, String groupId) { + int start = fromIdx; + while (start > 0 && _slotForIndex(start - 1)?.supersetGroupId == groupId) { + start--; + } + return start; + } + + /// Returns true if any exercise in [startIdx..endIdx] still has fewer sets + /// logged than its target (deload-adjusted). + bool _supersetNeedsMoreSets( + int startIdx, + int endIdx, + WorkoutProvider provider, + ) { + for (int i = startIdx; i <= endIdx; i++) { + final slot = _slotForIndex(i); + if (slot == null) continue; + final targetSets = (widget.programWeek?.isDeload == true) + ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp( + 1, + 99, + ) + : slot.sets; + final logged = i < provider.currentExerciseLogs.length + ? provider.currentExerciseLogs[i].sets.length + : 0; + if (logged < targetSets) return true; + } + return false; + } + void _initializeWorkout() { final provider = context.read(); @@ -1216,6 +1253,15 @@ class _WorkoutFlowScreenState extends State { final newSlot = _slotForIndex(provider.currentExerciseIndex); if (newSlot != null) setState(() => _restSeconds = newSlot.restSeconds); } else { + // Detect if we just finished the last exercise in a superset group. + // If the group still needs more sets, schedule a return after rest. + final groupId = currentSlot?.supersetGroupId; + if (groupId != null) { + final groupStart = _supersetGroupStart(currentIdx, groupId); + if (_supersetNeedsMoreSets(groupStart, currentIdx, provider)) { + _supersetReturnIndex = groupStart; + } + } _startRestTimer(); } } @@ -1237,10 +1283,22 @@ class _WorkoutFlowScreenState extends State { void _skipRest() { _restTimer?.cancel(); + final returnIdx = _supersetReturnIndex; setState(() { _isResting = false; _remainingSeconds = 0; + _supersetReturnIndex = null; }); + + // If in a superset cycle, auto-return to the first exercise in the group + if (returnIdx != null) { + final provider = context.read(); + provider.goToExercise(returnIdx); + _loadLastSessionData(); + final slot = _slotForIndex(returnIdx); + if (slot != null) setState(() => _restSeconds = slot.restSeconds); + } + HapticFeedback.lightImpact(); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 762e2e6..869000d 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -345,6 +345,14 @@ class WorkoutProvider extends ChangeNotifier { return false; } + /// Jump directly to an exercise by index + void goToExercise(int index) { + if (index >= 0 && index < _currentExerciseLogs.length) { + _currentExerciseIndex = index; + notifyListeners(); + } + } + /// Finish workout and save Future finishWorkout({String? notes}) async { final duration = _workoutStartTime != null From 46f4afbeda0c3ba917207695e9a1edd5e3af8648 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:16:27 +0530 Subject: [PATCH 08/11] feat: add active workout flow screen with rest timers, supersets, and dropsets, alongside program design and detail screens. --- .github/skills/flutter-expert/SKILL.md | 287 ++++++++++++++++++ .github/workflows/test.yml | 37 +++ .../programs/program_designer_screen.dart | 35 ++- .../programs/program_detail_screen.dart | 103 +++++-- .../lib/screens/workout_flow_screen.dart | 69 +++-- .../lib/services/workout_provider.dart | 15 +- workout-logger/test/program_manager_test.dart | 255 ++++++++++++++++ 7 files changed, 739 insertions(+), 62 deletions(-) create mode 100644 .github/skills/flutter-expert/SKILL.md create mode 100644 .github/workflows/test.yml create mode 100644 workout-logger/test/program_manager_test.dart diff --git a/.github/skills/flutter-expert/SKILL.md b/.github/skills/flutter-expert/SKILL.md new file mode 100644 index 0000000..8d806a4 --- /dev/null +++ b/.github/skills/flutter-expert/SKILL.md @@ -0,0 +1,287 @@ +--- +name: flutter-expert +description: "Use when building cross-platform mobile applications with Flutter 3+ that require custom UI implementation, complex state management, native platform integrations, or performance optimization across iOS/Android/Web." +tools: Read, Write, Edit, Bash, Glob, Grep +model: sonnet +--- + +You are a senior Flutter expert with expertise in Flutter 3+ and cross-platform mobile development. Your focus spans architecture patterns, state management, platform-specific implementations, and performance optimization with emphasis on creating applications that feel truly native on every platform. + + +When invoked: +1. Query context manager for Flutter project requirements and target platforms +2. Review app architecture, state management approach, and performance needs +3. Analyze platform requirements, UI/UX goals, and deployment strategies +4. Implement Flutter solutions with native performance and beautiful UI focus + +Flutter expert checklist: +- Flutter 3+ features utilized effectively +- Null safety enforced properly maintained +- Widget tests > 80% coverage achieved +- Performance 60 FPS consistently delivered +- Bundle size optimized thoroughly completed +- Platform parity maintained properly +- Accessibility support implemented correctly +- Code quality excellent achieved + +Flutter architecture: +- Clean architecture +- Feature-based structure +- Domain layer +- Data layer +- Presentation layer +- Dependency injection +- Repository pattern +- Use case pattern + +State management: +- Provider patterns +- Riverpod 2.0 +- BLoC/Cubit +- GetX reactive +- Redux implementation +- MobX patterns +- State restoration +- Performance comparison + +Widget composition: +- Custom widgets +- Composition patterns +- Render objects +- Custom painters +- Layout builders +- Inherited widgets +- Keys usage +- Performance widgets + +Platform features: +- iOS specific UI +- Android Material You +- Platform channels +- Native modules +- Method channels +- Event channels +- Platform views +- Native integration + +Custom animations: +- Animation controllers +- Tween animations +- Hero animations +- Implicit animations +- Custom transitions +- Staggered animations +- Physics simulations +- Performance tips + +Performance optimization: +- Widget rebuilds +- Const constructors +- RepaintBoundary +- ListView optimization +- Image caching +- Lazy loading +- Memory profiling +- DevTools usage + +Testing strategies: +- Widget testing +- Integration tests +- Golden tests +- Unit tests +- Mock patterns +- Test coverage +- CI/CD setup +- Device testing + +Multi-platform: +- iOS adaptation +- Android design +- Desktop support +- Web optimization +- Responsive design +- Adaptive layouts +- Platform detection +- Feature flags + +Deployment: +- App Store setup +- Play Store config +- Code signing +- Build flavors +- Environment config +- CI/CD pipeline +- Crashlytics +- Analytics setup + +Native integrations: +- Camera access +- Location services +- Push notifications +- Deep linking +- Biometric auth +- File storage +- Background tasks +- Native UI components + +## Communication Protocol + +### Flutter Context Assessment + +Initialize Flutter development by understanding cross-platform requirements. + +Flutter context query: +```json +{ + "requesting_agent": "flutter-expert", + "request_type": "get_flutter_context", + "payload": { + "query": "Flutter context needed: target platforms, app type, state management preference, native features required, and deployment strategy." + } +} +``` + +## Development Workflow + +Execute Flutter development through systematic phases: + +### 1. Architecture Planning + +Design scalable Flutter architecture. + +Planning priorities: +- App architecture +- State solution +- Navigation design +- Platform strategy +- Testing approach +- Deployment pipeline +- Performance goals +- UI/UX standards + +Architecture design: +- Define structure +- Choose state management +- Plan navigation +- Design data flow +- Set performance targets +- Configure platforms +- Setup CI/CD +- Document patterns + +### 2. Implementation Phase + +Build cross-platform Flutter applications. + +Implementation approach: +- Create architecture +- Build widgets +- Implement state +- Add navigation +- Platform features +- Write tests +- Optimize performance +- Deploy apps + +Flutter patterns: +- Widget composition +- State management +- Navigation patterns +- Platform adaptation +- Performance tuning +- Error handling +- Testing coverage +- Code organization + +Progress tracking: +```json +{ + "agent": "flutter-expert", + "status": "implementing", + "progress": { + "screens_completed": 32, + "custom_widgets": 45, + "test_coverage": "82%", + "performance_score": "60fps" + } +} +``` + +### 3. Flutter Excellence + +Deliver exceptional Flutter applications. + +Excellence checklist: +- Performance smooth +- UI beautiful +- Tests comprehensive +- Platforms consistent +- Animations fluid +- Native features working +- Documentation complete +- Deployment automated + +Delivery notification: +"Flutter application completed. Built 32 screens with 45 custom widgets achieving 82% test coverage. Maintained 60fps performance across iOS and Android. Implemented platform-specific features with native performance." + +Performance excellence: +- 60 FPS consistent +- Jank free scrolling +- Fast app startup +- Memory efficient +- Battery optimized +- Network efficient +- Image optimized +- Build size minimal + +UI/UX excellence: +- Material Design 3 +- iOS guidelines +- Custom themes +- Responsive layouts +- Adaptive designs +- Smooth animations +- Gesture handling +- Accessibility complete + +Platform excellence: +- iOS perfect +- Android polished +- Desktop ready +- Web optimized +- Platform consistent +- Native features +- Deep linking +- Push notifications + +Testing excellence: +- Widget tests thorough +- Integration complete +- Golden tests +- Performance tests +- Platform tests +- Accessibility tests +- Manual testing +- Automated deployment + +Best practices: +- Effective Dart +- Flutter style guide +- Null safety strict +- Linting configured +- Code generation +- Localization ready +- Error tracking +- Performance monitoring + +Integration with other agents: +- Collaborate with mobile-developer on mobile patterns +- Support dart specialist on Dart optimization +- Work with ui-designer on design implementation +- Guide performance-engineer on optimization +- Help qa-expert on testing strategies +- Assist devops-engineer on deployment +- Partner with backend-developer on API integration +- Coordinate with ios-developer on iOS specifics + +Always prioritize native performance, beautiful UI, and consistent experience while building Flutter applications that delight users across all platforms. \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..061aaa2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + release: + types: [published] + +jobs: + test: + name: Analyze & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.38.7' + channel: 'stable' + cache: true + + - name: Install dependencies + working-directory: ./workout-logger + run: flutter pub get + + - name: Analyze + working-directory: ./workout-logger + run: flutter analyze --no-fatal-infos + + - name: Run tests + working-directory: ./workout-logger + run: flutter test diff --git a/workout-logger/lib/screens/programs/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart index e9f0542..4e109cf 100644 --- a/workout-logger/lib/screens/programs/program_designer_screen.dart +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -805,6 +805,39 @@ class _ProgramDesignerScreenState extends State { return; } + if (_weeks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add at least one week before saving')), + ); + return; + } + + final hasEmptyDays = _weeks.any( + (w) => w.days.any((d) => d.exercises.isEmpty), + ); + if (hasEmptyDays) { + final proceed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Empty Days'), + content: const Text( + 'Some days have no exercises. Save anyway?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Save Anyway'), + ), + ], + ), + ); + if (proceed != true) return; + } + final provider = context.read(); final program = TrainingProgram( id: widget.existing?.id ?? _uuid.v4(), @@ -814,7 +847,7 @@ class _ProgramDesignerScreenState extends State { totalWeeks: _totalWeeks, phases: _phases, weeks: _weeks, - isImported: false, + isImported: widget.existing?.isImported ?? false, createdAt: widget.existing?.createdAt ?? DateTime.now(), ); diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index c48c570..3a50687 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -116,22 +116,22 @@ class _ProgramDetailScreenState extends State { Row( children: [ _statChip( - Icons.calendar_today, - '${_program.totalWeeks} weeks', - AppTheme.primaryColor, + icon: Icons.calendar_today, + label: '${_program.totalWeeks} weeks', + color: AppTheme.primaryColor, ), const SizedBox(width: AppSpacing.sm), _statChip( - Icons.bolt, - '${_program.phases.length} phases', - AppTheme.secondaryColor, + icon: Icons.bolt, + label: '${_program.phases.length} phases', + color: AppTheme.secondaryColor, ), const SizedBox(width: AppSpacing.sm), if (deloadCount > 0) _statChip( - Icons.battery_charging_full, - '$deloadCount deload${deloadCount > 1 ? 's' : ''}', - Colors.amber, + icon: Icons.battery_charging_full, + label: '$deloadCount deload${deloadCount > 1 ? 's' : ''}', + color: Colors.amber, ), ], ), @@ -164,7 +164,11 @@ class _ProgramDetailScreenState extends State { ); } - Widget _statChip(IconData icon, String label, Color color) { + Widget _statChip({ + required IconData icon, + required String label, + required Color color, + }) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -452,11 +456,35 @@ class _ProgramDetailScreenState extends State { Widget _buildDaySection(ProgramDay day, ProgramWeek week) { final provider = context.read(); - // Group exercises by superset group - final supersetGroups = >{}; + // Group exercises into contiguous runs by supersetGroupId + // to preserve original order (a map would collapse non-contiguous groups). + final runs = >[]; + String? currentRunKey; + List currentRun = []; for (final slot in day.exercises) { final key = slot.supersetGroupId; - supersetGroups.putIfAbsent(key, () => []).add(slot); + if (key == null) { + // Flush any open superset run + if (currentRun.isNotEmpty) { + runs.add(currentRun); + currentRun = []; + currentRunKey = null; + } + // Standalone exercise + runs.add([slot]); + } else if (key == currentRunKey) { + currentRun.add(slot); + } else { + // Flush previous run and start new + if (currentRun.isNotEmpty) { + runs.add(currentRun); + } + currentRunKey = key; + currentRun = [slot]; + } + } + if (currentRun.isNotEmpty) { + runs.add(currentRun); } return Padding( @@ -504,16 +532,20 @@ class _ProgramDetailScreenState extends State { ), const SizedBox(height: AppSpacing.sm), // Render standalone exercises and superset groups - ...supersetGroups.entries.map((entry) { - final slots = entry.value; - final isSuperset = entry.key != null; + ...runs.map((slots) { + final isSuperset = + slots.length > 1 || slots.first.supersetGroupId != null; if (isSuperset) { - return _buildSupersetGroup(slots, provider, week); + return _buildSupersetGroup( + slots: slots, + provider: provider, + week: week, + ); } - return Column( - children: slots - .map((slot) => _buildExerciseRow(slot, provider, week)) - .toList(), + return _buildExerciseRow( + slot: slots.first, + provider: provider, + week: week, ); }), const SizedBox(height: AppSpacing.sm), @@ -543,11 +575,11 @@ class _ProgramDetailScreenState extends State { ); } - Widget _buildSupersetGroup( - List slots, - WorkoutProvider provider, - ProgramWeek week, - ) { + Widget _buildSupersetGroup({ + required List slots, + required WorkoutProvider provider, + required ProgramWeek week, + }) { return Container( margin: const EdgeInsets.only(bottom: AppSpacing.sm), decoration: BoxDecoration( @@ -574,17 +606,22 @@ class _ProgramDetailScreenState extends State { ), ), ...slots.map( - (slot) => _buildExerciseRow(slot, provider, week, indent: true), + (slot) => _buildExerciseRow( + slot: slot, + provider: provider, + week: week, + indent: true, + ), ), ], ), ); } - Widget _buildExerciseRow( - ProgramExerciseSlot slot, - WorkoutProvider provider, - ProgramWeek week, { + Widget _buildExerciseRow({ + required ProgramExerciseSlot slot, + required WorkoutProvider provider, + required ProgramWeek week, bool indent = false, }) { final exercise = provider.getExercise(slot.exerciseId); @@ -693,12 +730,14 @@ class _ProgramDetailScreenState extends State { // ── Actions ────────────────────────────────────────────────────────── - void _handleMenuAction(String action) async { + void _handleMenuAction(String action) { switch (action) { case 'export': _exportProgram(); + break; case 'delete': _confirmDelete(); + break; } } diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 0ba402b..5f0d723 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -78,28 +78,42 @@ class _WorkoutFlowScreenState extends State { /// Returns true if any exercise in [startIdx..endIdx] still has fewer sets /// logged than its target (deload-adjusted). - bool _supersetNeedsMoreSets( - int startIdx, - int endIdx, - WorkoutProvider provider, - ) { + bool _supersetNeedsMoreSets({ + required int startIdx, + required int endIdx, + required WorkoutProvider provider, + }) { for (int i = startIdx; i <= endIdx; i++) { final slot = _slotForIndex(i); if (slot == null) continue; + if (i >= provider.currentExerciseLogs.length) continue; final targetSets = (widget.programWeek?.isDeload == true) ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp( 1, 99, ) : slot.sets; - final logged = i < provider.currentExerciseLogs.length - ? provider.currentExerciseLogs[i].sets.length - : 0; + final logged = provider.currentExerciseLogs[i].sets.length; if (logged < targetSets) return true; } return false; } + /// Returns true if the single slot at [index] still needs more sets. + bool _slotNeedsMoreSets({ + required int index, + required WorkoutProvider provider, + }) { + final slot = _slotForIndex(index); + if (slot == null) return false; + if (index >= provider.currentExerciseLogs.length) return false; + final targetSets = (widget.programWeek?.isDeload == true) + ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp(1, 99) + : slot.sets; + final logged = provider.currentExerciseLogs[index].sets.length; + return logged < targetSets; + } + void _initializeWorkout() { final provider = context.read(); @@ -1062,22 +1076,22 @@ class _WorkoutFlowScreenState extends State { runSpacing: 4, children: [ _programChip( - Icons.timer_outlined, - '${slot.restSeconds}s rest', - AppTheme.textSecondary, + icon: Icons.timer_outlined, + label: '${slot.restSeconds}s rest', + color: AppTheme.textSecondary, ), if (slot.tempo != null) - _programChip(Icons.speed, 'Tempo ${slot.tempo}', AppTheme.secondaryColor), + _programChip(icon: Icons.speed, label: 'Tempo ${slot.tempo}', color: AppTheme.secondaryColor), if (slot.weightPercentage != null) _programChip( - Icons.fitness_center, - week.isDeload + icon: Icons.fitness_center, + label: week.isDeload ? '${(slot.weightPercentage! * week.deloadIntensityFactor).toStringAsFixed(0)}% 1RM' : '${slot.weightPercentage!.toStringAsFixed(0)}% 1RM', - AppTheme.primaryColor, + color: AppTheme.primaryColor, ), if (slot.supersetGroupId != null) - _programChip(Icons.link, 'Superset', AppTheme.secondaryColor), + _programChip(icon: Icons.link, label: 'Superset', color: AppTheme.secondaryColor), ], ), if (slot.notes != null) ...[ @@ -1096,7 +1110,11 @@ class _WorkoutFlowScreenState extends State { ); } - Widget _programChip(IconData icon, String label, Color color) { + Widget _programChip({ + required IconData icon, + required String label, + required Color color, + }) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1241,12 +1259,13 @@ class _WorkoutFlowScreenState extends State { _drops.clear(); }); - // Superset auto-advance: if next exercise is in the same superset group, - // advance immediately without a rest timer + // Superset auto-advance: if next exercise is in the same superset group + // AND the next slot still needs more sets, advance immediately. final isSupersetPair = currentSlot?.supersetGroupId != null && nextSlot?.supersetGroupId == currentSlot?.supersetGroupId; - if (isSupersetPair) { + if (isSupersetPair && + _slotNeedsMoreSets(index: currentIdx + 1, provider: provider)) { provider.nextExercise(); _loadLastSessionData(); // Apply the next slot's rest time so the subsequent rest is correct @@ -1258,7 +1277,11 @@ class _WorkoutFlowScreenState extends State { final groupId = currentSlot?.supersetGroupId; if (groupId != null) { final groupStart = _supersetGroupStart(currentIdx, groupId); - if (_supersetNeedsMoreSets(groupStart, currentIdx, provider)) { + if (_supersetNeedsMoreSets( + startIdx: groupStart, + endIdx: currentIdx, + provider: provider, + )) { _supersetReturnIndex = groupStart; } } @@ -1304,8 +1327,8 @@ class _WorkoutFlowScreenState extends State { void _adjustRestTime(int seconds) { setState(() { - _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 300); - _restSeconds = (_restSeconds + seconds).clamp(30, 300); + _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600); + _restSeconds = (_restSeconds + seconds).clamp(30, 600); }); HapticFeedback.selectionClick(); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 869000d..f589419 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -37,7 +37,7 @@ class WorkoutProvider extends ChangeNotifier { final Map _growthModels = {}; // exerciseId -> GrowthModel - late final ProgramManager programManager; + final ProgramManager programManager; // Active workout state WorkoutSession? _activeSession; @@ -63,11 +63,14 @@ class WorkoutProvider extends ChangeNotifier { /// Create WorkoutProvider with dependency injection. /// /// Following Dependency Inversion Principle: accepts abstractions - /// rather than concrete implementations. - WorkoutProvider(this._storage, {IMLService? mlService}) - : _mlService = mlService ?? MLService() { - programManager = ProgramManager(_storage); - } + /// rather than concrete implementations. [programManager] defaults to a + /// new ProgramManager backed by the same storage if not provided. + WorkoutProvider( + this._storage, { + IMLService? mlService, + ProgramManager? programManager, + }) : _mlService = mlService ?? MLService(), + programManager = programManager ?? ProgramManager(_storage); // ==================== INITIALIZATION ==================== diff --git a/workout-logger/test/program_manager_test.dart b/workout-logger/test/program_manager_test.dart new file mode 100644 index 0000000..ef3586d --- /dev/null +++ b/workout-logger/test/program_manager_test.dart @@ -0,0 +1,255 @@ +// Unit Tests for ProgramManager +// +// Tests: CRUD operations, import/export, UUID reassignment, FormatException propagation. + +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'test_utils/mock_storage_service.dart'; + +void main() { + group('ProgramManager', () { + late MockStorageService mockStorage; + late ProgramManager manager; + + setUp(() async { + mockStorage = MockStorageService(); + manager = ProgramManager(mockStorage); + }); + + TrainingProgram sampleProgram({ + String id = 'test-id', + String name = 'Test Program', + bool isImported = false, + }) { + return TrainingProgram( + id: id, + name: name, + description: 'A test program', + author: 'Tester', + totalWeeks: 4, + phases: [ + TrainingPhase( + id: 'phase-1', + name: 'Foundation', + startWeek: 1, + endWeek: 4, + ), + ], + weeks: [ + ProgramWeek( + weekNumber: 1, + days: [ + ProgramDay( + id: 'day-1', + name: 'Push', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 3, + minReps: 8, + maxReps: 12, + restSeconds: 90, + ), + ], + ), + ], + ), + ], + isImported: isImported, + ); + } + + // ==================== CRUD ==================== + + group('saveProgram', () { + test('should save a new program', () async { + final program = sampleProgram(); + await manager.saveProgram(program); + + expect(manager.programs.length, 1); + expect(manager.programs.first.name, 'Test Program'); + }); + + test('should update an existing program (upsert)', () async { + final program = sampleProgram(); + await manager.saveProgram(program); + + final updated = program.copyWith(name: 'Updated Name'); + await manager.saveProgram(updated); + + expect(manager.programs.length, 1); + expect(manager.programs.first.name, 'Updated Name'); + }); + + test('should persist to storage', () async { + final program = sampleProgram(); + await manager.saveProgram(program); + + final storedPrograms = await mockStorage.getAllTrainingPrograms(); + expect(storedPrograms.length, 1); + expect(storedPrograms.first.id, program.id); + }); + }); + + group('deleteProgram', () { + test('should remove program from in-memory list', () async { + final program = sampleProgram(); + await manager.saveProgram(program); + + await manager.deleteProgram(program.id); + + expect(manager.programs, isEmpty); + }); + + test('should remove program from storage', () async { + final program = sampleProgram(); + await manager.saveProgram(program); + + await manager.deleteProgram(program.id); + + final storedPrograms = await mockStorage.getAllTrainingPrograms(); + expect(storedPrograms, isEmpty); + }); + }); + + group('loadPrograms', () { + test('should populate from storage', () async { + // Pre-populate storage + final program = sampleProgram(); + await mockStorage.saveTrainingProgram(program); + + await manager.loadPrograms(); + + expect(manager.programs.length, 1); + expect(manager.programs.first.name, 'Test Program'); + }); + }); + + group('getProgramById', () { + test('should return program when found', () async { + final program = sampleProgram(id: 'find-me'); + await manager.saveProgram(program); + + final found = manager.getProgramById('find-me'); + expect(found, isNotNull); + expect(found!.id, 'find-me'); + }); + + test('should return null when not found', () { + final found = manager.getProgramById('nonexistent'); + expect(found, isNull); + }); + }); + + // ==================== IMPORT / EXPORT ==================== + + group('importFromJson', () { + test('should assign a new UUID on import', () async { + final original = sampleProgram(id: 'original-id'); + final json = const JsonEncoder.withIndent(' ').convert( + original.toJson(), + ); + + final imported = await manager.importFromJson(json); + + expect(imported.id, isNot(equals('original-id'))); + }); + + test('should mark isImported = true', () async { + final original = sampleProgram(isImported: false); + final json = jsonEncode(original.toJson()); + + final imported = await manager.importFromJson(json); + + expect(imported.isImported, isTrue); + }); + + test('should set a new createdAt timestamp', () async { + final original = sampleProgram(); + final originalCreatedAt = original.createdAt; + final json = jsonEncode(original.toJson()); + + // Small delay to ensure different timestamp + await Future.delayed(const Duration(milliseconds: 10)); + final imported = await manager.importFromJson(json); + + // The imported createdAt should be >= the original + expect( + imported.createdAt.millisecondsSinceEpoch, + greaterThanOrEqualTo(originalCreatedAt.millisecondsSinceEpoch), + ); + }); + + test('should persist the imported program', () async { + final original = sampleProgram(); + final json = jsonEncode(original.toJson()); + + await manager.importFromJson(json); + + expect(manager.programs.length, 1); + final storedPrograms = await mockStorage.getAllTrainingPrograms(); + expect(storedPrograms.length, 1); + }); + + test('should throw FormatException on malformed JSON', () async { + expect( + () => manager.importFromJson('not valid json'), + throwsA(isA()), + ); + }); + + test('should throw on missing required fields', () async { + const incompleteJson = '{"id": "x"}'; + expect( + () => manager.importFromJson(incompleteJson), + throwsA(isA()), + ); + }); + }); + + group('exportToJson', () { + test('should produce valid JSON', () { + final program = sampleProgram(); + final json = manager.exportToJson(program); + + // Should not throw + final decoded = jsonDecode(json) as Map; + expect(decoded['name'], 'Test Program'); + }); + + test('export → import round-trip preserves data', () async { + final original = sampleProgram(); + final json = manager.exportToJson(original); + + final imported = await manager.importFromJson(json); + + // Core data should match (id, isImported, createdAt are intentionally different) + expect(imported.name, original.name); + expect(imported.description, original.description); + expect(imported.author, original.author); + expect(imported.totalWeeks, original.totalWeeks); + expect(imported.phases.length, original.phases.length); + expect(imported.weeks.length, original.weeks.length); + expect( + imported.weeks.first.days.first.exercises.first.exerciseId, + original.weeks.first.days.first.exercises.first.exerciseId, + ); + }); + }); + + group('createProgram', () { + test('should create and save a new program', () async { + final program = await manager.createProgram( + name: 'Created Program', + totalWeeks: 6, + ); + + expect(program.name, 'Created Program'); + expect(program.totalWeeks, 6); + expect(manager.programs.length, 1); + }); + }); + }); +} From 6f4713237b4bc5ff752ed3c8cc467bbb38aba400 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:47:03 +0530 Subject: [PATCH 09/11] Adds bypass on info and warnings in analyze phase, and --- .github/workflows/test.yml | 18 +++++++++++++++--- workout-logger/pubspec.yaml | 1 + 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 061aaa2..a8303fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,22 +15,34 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Flutter + id: flutter-action uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.7' + flutter-version-file: './workout-logger/pubspec.yaml' channel: 'stable' cache: true + # Custom pub cache key hashing pubspec.lock to invalidate cache on dependency changes + pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-${{ hashFiles('workout-logger/pubspec.lock') }}" - name: Install dependencies + if: steps.flutter-action.outputs.PUB-CACHE-HIT != 'true' working-directory: ./workout-logger run: flutter pub get - name: Analyze working-directory: ./workout-logger - run: flutter analyze --no-fatal-infos + run: | + # Only fail on errors, ignore warnings and info messages + flutter analyze --no-fatal-infos --no-fatal-warnings | tee analyze_output.txt + + # Extract and display the summary of issues + SUMMARY=$(tail -n 1 analyze_output.txt) + echo "Analysis Summary: $SUMMARY" + echo "### Analysis Summary" >> $GITHUB_STEP_SUMMARY + echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY - name: Run tests working-directory: ./workout-logger diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index 4566efd..a9501de 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -20,6 +20,7 @@ version: 1.0.12+13 environment: sdk: ^3.9.2 + flutter: 3.41.5 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions From 0937cbe50da7f63b459a241210ff5a8cce538edf Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:55:36 +0530 Subject: [PATCH 10/11] test: Introduce widget tests for ExerciseLibraryScreen and AddCustomExerciseScreen, along with project configuration in pubspec.yaml. --- .../test/add_custom_exercise_screen_test.dart | 6 ++++++ workout-logger/test/exercise_library_screen_test.dart | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index 777cf51..ca286cc 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -39,6 +39,8 @@ void main() { // Act - Try to save without entering a name // First select a muscle group (required) + await tester.ensureVisible(find.text('Chest')); + await tester.pumpAndSettle(); await tester.tap(find.text('Chest')); await tester.pump(); @@ -63,6 +65,8 @@ void main() { // Act - Enter a short name await tester.enterText(find.byType(TextFormField), 'Ab'); + await tester.ensureVisible(find.text('Chest')); + await tester.pumpAndSettle(); await tester.tap(find.text('Chest')); await tester.pump(); @@ -112,6 +116,8 @@ void main() { await tester.pump(); // Select a muscle group + await tester.ensureVisible(find.text('Shoulders')); + await tester.pumpAndSettle(); await tester.tap(find.text('Shoulders')); await tester.pump(); diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 52c1436..6d08d28 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -125,12 +125,12 @@ void main() { testWidgets('should filter exercises by search query', (tester) async { // Arrange - Add custom exercises await provider.addCustomExercise( - name: 'Bicep Curl', + name: 'Unique Bicep Curl', category: 'isolation', primaryMuscleGroupId: 'biceps', ); await provider.addCustomExercise( - name: 'Tricep Pushdown', + name: 'Unique Tricep Pushdown', category: 'isolation', primaryMuscleGroupId: 'triceps', ); @@ -144,12 +144,12 @@ void main() { await tester.pumpAndSettle(); // Act - Enter search query - await tester.enterText(find.byType(TextField), 'Bicep'); + await tester.enterText(find.byType(TextField), 'Unique Bicep'); await tester.pumpAndSettle(); // Assert - Should only show matching exercise - expect(find.text('Bicep Curl'), findsOneWidget); - expect(find.text('Tricep Pushdown'), findsNothing); + expect(find.text('Unique Bicep Curl'), findsOneWidget); + expect(find.text('Unique Tricep Pushdown'), findsNothing); }); testWidgets('should show muscle group filter chips', (tester) async { From d14e4363a2b0c2260a6489d965f2f85870b93c7e Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:00:59 +0530 Subject: [PATCH 11/11] feat: Implement WorkoutProvider for managing workout state, custom exercises, and workout flow, supported by new screens and tests. --- workout-logger/lib/main.dart | 11 ++- .../programs/program_designer_screen.dart | 78 ++++++++++++++++--- .../lib/screens/workout_flow_screen.dart | 4 +- .../lib/services/workout_provider.dart | 5 +- .../test/add_custom_exercise_screen_test.dart | 3 +- .../test/exercise_library_screen_test.dart | 5 +- .../test/workout_provider_test.dart | 3 +- 7 files changed, 88 insertions(+), 21 deletions(-) diff --git a/workout-logger/lib/main.dart b/workout-logger/lib/main.dart index 3c62870..253fe7a 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -13,6 +13,7 @@ import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; import 'services/api_service.dart'; +import 'services/managers/program_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -43,6 +44,7 @@ class WorkoutLoggerApp extends StatelessWidget { // This ensures the same instances are used throughout the app lifecycle static final IStorageService _storageService = StorageService(); static final IMLService _mlService = MLService(); + static final ProgramManager _programManager = ProgramManager(_storageService); const WorkoutLoggerApp({super.key}); @@ -59,10 +61,15 @@ class WorkoutLoggerApp extends StatelessWidget { Provider.value(value: _mlService), // Provide the ApiService singleton via DI Provider.value(value: ApiService()), + // ProgramManager passed to tree directly + ChangeNotifierProvider.value(value: _programManager), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( - create: (_) => - WorkoutProvider(_storageService, mlService: _mlService), + create: (_) => WorkoutProvider( + _storageService, + mlService: _mlService, + programManager: _programManager, + ), ), ], child: MaterialApp( diff --git a/workout-logger/lib/screens/programs/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart index 4e109cf..ecf4c70 100644 --- a/workout-logger/lib/screens/programs/program_designer_screen.dart +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -142,10 +142,18 @@ class _ProgramDesignerScreenState extends State { min: 1, max: 52, onChanged: (v) { - setState(() { - _totalWeeks = v; - _rebuildWeeks(); - }); + final error = _validatePhases(v); + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(error), + backgroundColor: AppTheme.error, + )); + } else { + setState(() { + _totalWeeks = v; + _rebuildWeeks(); + }); + } }, ), const SizedBox(height: AppSpacing.lg), @@ -232,13 +240,30 @@ class _ProgramDesignerScreenState extends State { startWeek: start, endWeek: end, ); - setState(() { + + final backupPhase = idx != null ? _phases[idx] : null; + if (idx != null) { + _phases[idx] = phase; + } else { + _phases.add(phase); + } + + final error = _validatePhases(_totalWeeks); + if (error != null) { + // Revert if (idx != null) { - _phases[idx] = phase; + _phases[idx] = backupPhase!; } else { - _phases.add(phase); + _phases.removeLast(); } - }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(error), + backgroundColor: AppTheme.error, + )); + return; // Prevent closing + } + + setState(() {}); Navigator.pop(ctx); }, child: const Text('Save'), @@ -721,6 +746,7 @@ class _ProgramDesignerScreenState extends State { tempo: tempoCtrl.text.isEmpty ? null : tempoCtrl.text, weightPercentage: double.tryParse(weightPctCtrl.text), notes: notesCtrl.text.isEmpty ? null : notesCtrl.text, + supersetGroupId: existing?.supersetGroupId, ); setState(() { final exercises = List.from( @@ -805,6 +831,14 @@ class _ProgramDesignerScreenState extends State { return; } + final phaseError = _validatePhases(_totalWeeks); + if (phaseError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(phaseError), backgroundColor: AppTheme.error), + ); + return; + } + if (_weeks.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Add at least one week before saving')), @@ -857,6 +891,30 @@ class _ProgramDesignerScreenState extends State { // ── Helpers ─────────────────────────────────────────────────────────── + String? _validatePhases(int proposedTotalWeeks) { + if (_phases.isEmpty) return null; + + // Detect truncation + for (final phase in _phases) { + if (phase.endWeek > proposedTotalWeeks) { + return 'Cannot reduce weeks to $proposedTotalWeeks. Phase "${phase.name}" ends on week ${phase.endWeek}.'; + } + if (phase.startWeek > phase.endWeek) { + return 'Phase "${phase.name}" has an invalid range (starts after it ends).'; + } + } + + // Detect overlapping + final sortedPhases = List.from(_phases)..sort((a, b) => a.startWeek.compareTo(b.startWeek)); + for (int i = 0; i < sortedPhases.length - 1; i++) { + if (sortedPhases[i].endWeek >= sortedPhases[i + 1].startWeek) { + return 'Phases "${sortedPhases[i].name}" and "${sortedPhases[i + 1].name}" overlap.'; + } + } + + return null; + } + Widget _field( TextEditingController ctrl, String label, { @@ -928,7 +986,7 @@ class _NumberStepper extends StatelessWidget { IconButton( icon: const Icon(Icons.remove, size: 18), onPressed: value > min - ? () => onChanged((value - step).clamp(min, max)) + ? () => onChanged((value - step).clamp(min, max).toInt()) : null, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), @@ -947,7 +1005,7 @@ class _NumberStepper extends StatelessWidget { IconButton( icon: const Icon(Icons.add, size: 18), onPressed: value < max - ? () => onChanged((value + step).clamp(min, max)) + ? () => onChanged((value + step).clamp(min, max).toInt()) : null, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 5f0d723..b8af731 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -1327,8 +1327,8 @@ class _WorkoutFlowScreenState extends State { void _adjustRestTime(int seconds) { setState(() { - _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600); - _restSeconds = (_restSeconds + seconds).clamp(30, 600); + _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600).toInt(); + _restSeconds = (_restSeconds + seconds).clamp(30, 600).toInt(); }); HapticFeedback.selectionClick(); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index f589419..86d3cba 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -68,9 +68,8 @@ class WorkoutProvider extends ChangeNotifier { WorkoutProvider( this._storage, { IMLService? mlService, - ProgramManager? programManager, - }) : _mlService = mlService ?? MLService(), - programManager = programManager ?? ProgramManager(_storage); + required this.programManager, + }) : _mlService = mlService ?? MLService(); // ==================== INITIALIZATION ==================== diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index ca286cc..c4e260e 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/screens/add_custom_exercise_screen.dart'; import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ @@ -24,7 +25,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage); + provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); await provider.init(); }); diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 6d08d28..3eb9da7 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:repforge/screens/exercise_library_screen.dart'; import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; Widget createTestWidget({ @@ -24,7 +25,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage); + provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); await provider.init(); }); @@ -193,7 +194,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage); + provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); await provider.init(); // Add a custom exercise for delete tests diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index 8bac463..1171d28 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/services/workout_provider.dart'; +import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; void main() { @@ -11,7 +12,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage); + provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); await provider.init(); });