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..a8303fc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +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@v6 + + - name: Set up Flutter + id: flutter-action + uses: subosito/flutter-action@v2 + with: + 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: | + # 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 + run: flutter test 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/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/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/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart new file mode 100644 index 0000000..08938ed --- /dev/null +++ b/workout-logger/lib/screens/programs/import_program_screen.dart @@ -0,0 +1,393 @@ +// Import Program Screen +// +// 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 +// +// Other features: +// • Expandable text area that fills the screen +// • 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'; + +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: [ + IconButton( + icon: const Icon(Icons.folder_open), + tooltip: 'Pick JSON file', + onPressed: _pickFile, + ), + 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( + '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, + ), + ), + ); + } + + // ── 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 ───────────────────────────────────────────────────────────── + + 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(() { + _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/program_designer_screen.dart b/workout-logger/lib/screens/programs/program_designer_screen.dart new file mode 100644 index 0000000..ecf4c70 --- /dev/null +++ b/workout-logger/lib/screens/programs/program_designer_screen.dart @@ -0,0 +1,1017 @@ +// 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) { + 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), + 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, + ); + + 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] = backupPhase!; + } else { + _phases.removeLast(); + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(error), + backgroundColor: AppTheme.error, + )); + return; // Prevent closing + } + + setState(() {}); + 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, + supersetGroupId: existing?.supersetGroupId, + ); + 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 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')), + ); + 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(), + 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: widget.existing?.isImported ?? false, + createdAt: widget.existing?.createdAt ?? DateTime.now(), + ); + + await provider.programManager.saveProgram(program); + if (mounted) Navigator.pop(context); + } + + // ── 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, { + 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).toInt()) + : 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).toInt()) + : 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..3a50687 --- /dev/null +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -0,0 +1,828 @@ +// 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'; +import '../workout_flow_screen.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( + icon: Icons.calendar_today, + label: '${_program.totalWeeks} weeks', + color: AppTheme.primaryColor, + ), + const SizedBox(width: AppSpacing.sm), + _statChip( + icon: Icons.bolt, + label: '${_program.phases.length} phases', + color: AppTheme.secondaryColor, + ), + const SizedBox(width: AppSpacing.sm), + if (deloadCount > 0) + _statChip( + icon: Icons.battery_charging_full, + label: '$deloadCount deload${deloadCount > 1 ? 's' : ''}', + color: 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({ + required IconData icon, + required String label, + required 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 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; + 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( + 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 + ...runs.map((slots) { + final isSuperset = + slots.length > 1 || slots.first.supersetGroupId != null; + if (isSuperset) { + return _buildSupersetGroup( + slots: slots, + provider: provider, + week: week, + ); + } + return _buildExerciseRow( + slot: slots.first, + provider: provider, + week: week, + ); + }), + 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), + ], + ), + ); + } + + Widget _buildSupersetGroup({ + required List slots, + required WorkoutProvider provider, + required 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: slot, + provider: provider, + week: week, + indent: true, + ), + ), + ], + ), + ); + } + + Widget _buildExerciseRow({ + required ProgramExerciseSlot slot, + required WorkoutProvider provider, + required 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) { + switch (action) { + case 'export': + _exportProgram(); + break; + case 'delete': + _confirmDelete(); + break; + } + } + + 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..328bab1 --- /dev/null +++ b/workout-logger/lib/screens/programs/programs_screen.dart @@ -0,0 +1,312 @@ +// 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 (full-screen ImportProgramScreen) + +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'; +import 'import_program_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: () => _openImport(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: () => _openImport(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), + ), + ); + } + + Future _openImport(BuildContext context) async { + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ImportProgramScreen()), + ); + if (result == true && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Program imported successfully!')), + ); + } + } +} + +// ── 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/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index a2bcc06..b8af731 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(); @@ -27,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; @@ -49,10 +60,72 @@ 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; + } + + /// 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({ + 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 = 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(); - 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 +233,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 +246,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 +421,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 +1009,122 @@ 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( + icon: Icons.timer_outlined, + label: '${slot.restSeconds}s rest', + color: AppTheme.textSecondary, + ), + if (slot.tempo != null) + _programChip(icon: Icons.speed, label: 'Tempo ${slot.tempo}', color: AppTheme.secondaryColor), + if (slot.weightPercentage != null) + _programChip( + icon: Icons.fitness_center, + label: week.isDeload + ? '${(slot.weightPercentage! * week.deloadIntensityFactor).toStringAsFixed(0)}% 1RM' + : '${slot.weightPercentage!.toStringAsFixed(0)}% 1RM', + color: AppTheme.primaryColor, + ), + if (slot.supersetGroupId != null) + _programChip(icon: Icons.link, label: 'Superset', color: 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({ + required IconData icon, + required String label, + required 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 +1225,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 +1239,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 +1259,34 @@ class _WorkoutFlowScreenState extends State { _drops.clear(); }); - // Start rest timer - _startRestTimer(); + // 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 && + _slotNeedsMoreSets(index: currentIdx + 1, provider: provider)) { + 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 { + // 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( + startIdx: groupStart, + endIdx: currentIdx, + provider: provider, + )) { + _supersetReturnIndex = groupStart; + } + } + _startRestTimer(); + } } void _startRestTimer() { @@ -1074,17 +1306,29 @@ 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(); } void _adjustRestTime(int seconds) { setState(() { - _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 300); - _restSeconds = (_restSeconds + seconds).clamp(30, 300); + _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 600).toInt(); + _restSeconds = (_restSeconds + seconds).clamp(30, 600).toInt(); }); HapticFeedback.selectionClick(); } 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 18ffa74..25cc76c 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -23,6 +23,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; @@ -30,6 +31,7 @@ class StorageService implements IStorageService { late Box _muscleGroupsBoxInstance; late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; + late Box _trainingProgramsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -63,6 +65,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) { @@ -443,6 +448,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 ==================== @override diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 2f970ff..86d3cba 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 + final ProgramManager programManager; + // Active workout state WorkoutSession? _activeSession; Routine? _activeRoutine; @@ -59,9 +63,13 @@ 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(); + /// rather than concrete implementations. [programManager] defaults to a + /// new ProgramManager backed by the same storage if not provided. + WorkoutProvider( + this._storage, { + IMLService? mlService, + required this.programManager, + }) : _mlService = mlService ?? MLService(); // ==================== INITIALIZATION ==================== @@ -77,6 +85,7 @@ class WorkoutProvider extends ChangeNotifier { _targets = await _storage.getAllTargets(); _muscleGroups = await _storage.getAllMuscleGroups(); _allExercises = await _storage.getAllExercises(); + await programManager.loadPrograms(); notifyListeners(); } @@ -338,6 +347,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 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 diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index 777cf51..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(); }); @@ -39,6 +40,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 +66,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 +117,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..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(); }); @@ -125,12 +126,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 +145,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 { @@ -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/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); + }); + }); + }); +} 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 => '{}'; 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(); });