diff --git a/.github/skills/flutter-expert/SKILL.md b/.github/skills/flutter-expert/SKILL.md deleted file mode 100644 index 8d806a4..0000000 --- a/.github/skills/flutter-expert/SKILL.md +++ /dev/null @@ -1,287 +0,0 @@ ---- -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/release.yml b/.github/workflows/release.yml index a4dda78..865e0dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,6 @@ on: push: branches: - main - workflow_dispatch: jobs: release: @@ -13,8 +12,7 @@ jobs: runs-on: ubuntu-latest permissions: - contents: write # Required to create releases and push commits - pull-requests: read # Required to read merged PRs for release notes + contents: write # Required to create releases and push commits steps: - name: Checkout repository @@ -32,36 +30,26 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version-file: './workout-logger/pubspec.yaml' + flutter-version: '3.38.7' channel: 'stable' cache: true - pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-${{ hashFiles('workout-logger/pubspec.lock') }}" - name: Install dependencies working-directory: ./workout-logger run: flutter pub get - name: Bump version - if: github.event_name == 'push' id: bump_version run: | dart scripts/bump_version.dart patch echo "Version bumped successfully" - - name: Read version - id: version - run: | - VERSION=$(grep '^version:' workout-logger/pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) - echo "value=$VERSION" >> $GITHUB_OUTPUT - - name: Configure Git - if: github.event_name == 'push' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Commit version bump - if: github.event_name == 'push' id: commit_version run: | git add workout-logger/pubspec.yaml @@ -69,51 +57,16 @@ jobs: echo "No changes to commit" echo "committed=false" >> $GITHUB_OUTPUT else - git commit -m "chore: bump version to ${{ steps.version.outputs.value }}" - git push origin HEAD:${{ github.ref_name }} + git commit -m "chore: bump version to ${{ steps.bump_version.outputs.version }}" + git push origin main echo "committed=true" >> $GITHUB_OUTPUT fi - name: Create Git tag - if: github.event_name == 'push' && steps.commit_version.outputs.committed == 'true' - run: | - git tag "v${{ steps.version.outputs.value }}" - git push origin "v${{ steps.version.outputs.value }}" - - - name: Get merged PRs for release notes - if: github.event_name == 'push' && steps.commit_version.outputs.committed == 'true' - id: merged_prs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: steps.commit_version.outputs.committed == 'true' run: | - NEW_TAG="v${{ steps.version.outputs.value }}" - PREV_TAG=$(git tag --sort=-version:refname | grep '^v' | grep -v "^${NEW_TAG}$" | head -1) - - if [ -n "$PREV_TAG" ]; then - SINCE_DATE=$(git log -1 --format=%aI "$PREV_TAG") - PR_LIST=$(gh pr list \ - --state merged \ - --base main \ - --limit 50 \ - --search "merged:>=${SINCE_DATE}" \ - --json number,title,author \ - --template '{{range .}}- #{{.number}} {{.title}} (@{{.author.login}}){{"\n"}}{{end}}' 2>/dev/null || echo "") - else - PR_LIST=$(gh pr list \ - --state merged \ - --base main \ - --limit 10 \ - --json number,title,author \ - --template '{{range .}}- #{{.number}} {{.title}} (@{{.author.login}}){{"\n"}}{{end}}' 2>/dev/null || echo "") - fi - - [ -z "$PR_LIST" ] && PR_LIST="_No merged pull requests found since the last release._" - - { - echo "list<> $GITHUB_OUTPUT + git tag "v${{ steps.bump_version.outputs.version }}" + git push origin "v${{ steps.bump_version.outputs.version }}" - name: Build APK working-directory: ./workout-logger @@ -122,41 +75,28 @@ jobs: - name: Rename APK run: | mv workout-logger/build/app/outputs/flutter-apk/app-release.apk \ - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk - - - name: Sanitize ref name for artifact - id: sanitize_ref - run: echo "ref_name=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> $GITHUB_OUTPUT - - - name: Upload APK artifact (manual run) - if: github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 - with: - name: repforge-v${{ steps.version.outputs.value }}-${{ steps.sanitize_ref.outputs.ref_name }} - path: workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk - retention-days: 7 + workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.bump_version.outputs.version }}.apk - name: Create GitHub Release - if: github.event_name == 'push' && steps.commit_version.outputs.committed == 'true' uses: softprops/action-gh-release@v2 with: - tag_name: v${{ steps.version.outputs.value }} - name: RepForge v${{ steps.version.outputs.value }} + tag_name: v${{ steps.bump_version.outputs.version }} + name: RepForge v${{ steps.bump_version.outputs.version }} body: | - ## RepForge v${{ steps.version.outputs.value }} + ## RepForge v${{ steps.bump_version.outputs.version }} ### ๐Ÿ“ฑ Download Download the APK file below to install on your Android device. - ### ๐Ÿ”€ Merged Pull Requests - ${{ steps.merged_prs.outputs.list }} + ### ๐Ÿ”„ Changes + This release was automatically generated from the latest changes merged to main. ### ๐Ÿ“Š Build Information - - **Version**: ${{ steps.version.outputs.value }} + - **Version**: ${{ steps.bump_version.outputs.version }} - **Build Date**: ${{ github.event.head_commit.timestamp }} - **Commit**: ${{ github.sha }} files: | - workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.version.outputs.value }}.apk + workout-logger/build/app/outputs/flutter-apk/repforge-v${{ steps.bump_version.outputs.version }}.apk draft: false prerelease: false env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index a8303fc..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,49 +0,0 @@ -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/12week_plan.html b/12week_plan.html deleted file mode 100644 index 5d46c0d..0000000 --- a/12week_plan.html +++ /dev/null @@ -1,1101 +0,0 @@ - - - - - -12-Week Training Plan - - - - -
-
// YOUR PERSONAL PLAN โ€” AESTHETICS + ENDURANCE
-

12-WEEK
TRANSFORMATION

-
-
3Phases
-
5Training Days
-
60Min / Session
-
~16Sets / Muscle / Wk
-
8โ€“15Rep Range
-
-
- - - -
- - -
- -
-
-
Phase 1 โ€” Foundation (Weeks 1โ€“4)
-
Build the habit, learn every movement with perfect form. Volume is moderate. Focus on mind-muscle connection, especially biceps and shoulders. Every set should be felt, not just done.
-
-
-
3ร—10
Sets ร— Reps
-
75s
Rest
-
RPE 7
Effort
-
-
- -
-
-
MON
- PUSH -
Chest ยท Triceps
Front Delt
-
-
-
TUE
- REST + REHAB -
Wall Sits ยท Tibialis
Clamshells ยท TKE
-
-
-
WED
- CORE -
Abs ยท Anti-rotation
Lower back
-
-
-
THU
- PULL -
Back ยท Biceps
Rear Delt
-
-
-
FRI
- SHOULDERS -
All 3 delt heads
+ Arms finisher
-
-
-
SAT
- LEGS + REHAB -
Leg press ยท Curls
Extensions + Rehab
-
-
-
SUN
- REST -
Walk / stretch
Full recovery
-
-
- -
-
-
Phase 2 โ€” Intensify (Weeks 5โ€“8)
-
Add a 4th set across all major lifts. Introduce supersets for arms. Rest drops to 60s for isolation work. This is where visible changes start โ€” biceps and shoulders get hit directly twice a week.
-
-
-
4ร—10
Sets ร— Reps
-
60s
Rest
-
RPE 8
Effort
-
-
- -

- Same split as Phase 1. Changes are in volume and intensity per session โ€” see Workouts tab for Phase 2 notes. -

- -
-
-
Phase 3 โ€” Peak (Weeks 9โ€“12)
-
Higher reps (12โ€“15), shorter rest (45โ€“60s), tri-sets for arms and shoulders. This phase maximises muscle endurance and the "pump" aesthetic โ€” dense, defined muscles. By week 12 people will notice.
-
-
-
4ร—12
Sets ร— Reps
-
45s
Rest (iso)
-
RPE 8-9
Effort
-
-
- -
-
Why This Split Fixes Your Visibility Problem
-
-
Arms hit 3ร—/week
Mon (triceps), Thu (biceps), Fri (both as finisher). Your current plan hits each muscle once. This triples direct arm stimulus.
-
Shoulders every session
Lateral raises on Mon, rear delts on Thu, full shoulder day on Fri. Shoulders are the #1 muscle that makes you look built in a t-shirt.
-
Back still prioritised
Your cable row and lat pulldown are your strongest lifts โ€” kept and built on. V-taper + bigger arms = completely different look by week 12.
-
-
- -
- - -
- -
- - - - - -
- - -
-
-
-
-
MONDAY โ€” PUSH (Chest + Triceps + Front Delt)
-
~55 min ยท P1: 3ร—10-12 ยท P2: 4ร—10 ยท P3: 4ร—10-12 + superset finisher
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#ExerciseSets ร— RepsRestTempoNotes
1Incline Bench Press (Smith / DB)P1: 3ร—10
P2: 4ร—8-10
P3: 4ร—10
90s3-1-1Primary strength lift. 3s down, 1s pause at bottom, 1s up. Break your 40kg plateau here. Start session with this when fresh.
2Flat Bench Press (Smith)P1: 3ร—10
P2: 4ร—10
P3: 4ร—12
75s2-0-1Second compound. Don't skip this โ€” chest width comes from flat. Start 5kg lighter than incline.
3Pec Deck (Cable Fly)P1: 3ร—12
P2: 3ร—12
P3: 4ร—15
60s2-1-2Squeeze hard at peak contraction. Hold 1s. This builds chest definition, not just mass.
4Overhead Tricep Extension (Cable)P1: 3ร—12
P2: 4ร—12
P3: 4ร—15
60s3-0-1Long head of tricep โ€” biggest part. Elbows stay still, only forearm moves. You've been at 30kg โ€” target 35kg by week 8.
5A ATricep Pushdown (rope attachment)P1: 3ร—12
P2: 3ร—12
P3: 3ร—15
โ€”2-1-1SUPERSET with 5B (P2/P3 only). Flare elbows out at bottom, squeeze.
5B BLateral Raise (DB, standing)P1: โ€”
P2: 3ร—15
P3: 3ร—15
60s2-1-2Added in P2. Slight forward lean, lead with elbows not hands. 10kg โ†’ target 12.5 by week 12.
-
-
- - - - - - - - - - - - - -
- - -
- -

- These are specific, measurable checkpoints. At each milestone, evaluate whether you're on track. If a target feels easy, push the next one harder. If it feels impossible, your form might need work โ€” don't just grind through bad reps. -

- -
- -
-
// WEEK 2 โ€” First check-in
-
Foundation Locked
-
You should be hitting every exercise with clean form. The goal this week is not weight โ€” it's consistency and mind-muscle connection. Focus especially on feeling your biceps, not just moving the weight.
-
- All 5 days completed โœ“ - Plank โ‰ฅ 30s - Knee rehab done both Tue + Sat - Bicep curl with full 3s eccentric -
-
- -
-
// END OF WEEK 4 โ€” Phase 1 Complete
-
First Strength Benchmarks
-
By week 4 you should have baseline numbers across every lift. These will be your reference point for all future progression. If any of these feel easy, you started too light โ€” good, now push faster.
-
- Incline Bench โ†’ 42.5kg ร— 10 - Cable Row โ†’ 87.5kg ร— 10 - Lat Pulldown โ†’ 57.5kg ร— 10 - Bicep Curl โ†’ 8.75kg ร— 12 - Preacher Curl โ†’ 16kg ร— 12 - Shoulder Press โ†’ first 5 sessions done - Plank โ†’ 45s - Wall Sit โ†’ 45s -
-
- -
-
// END OF WEEK 6 โ€” Mid-Phase 2
-
Visible Changes Begin
-
Shoulders should look noticeably wider than week 1 โ€” the dedicated Friday session + lateral raises twice a week does this fast. Arms should feel different when you flex. This is where the "nobody can tell" problem starts to reverse.
-
- Lat Pulldown โ†’ 60kg ร— 10 - Hammer Curl โ†’ 11.25kg ร— 12 - Lateral Raise โ†’ 11.25kg ร— 12 - Supersets introduced on Thu + Fri - Clamshells with band added - TKE โ€” zero pain with 3 sets -
-
- -
-
// END OF WEEK 8 โ€” Phase 2 Complete
-
Strength Milestone โ€” Major Jump
-
This is your biggest strength checkpoint. 8 weeks of consistent training should translate to meaningful weight increases across all lifts. Your arms and shoulders should now be visibly developed โ€” the weekly arm tri-frequency is kicking in.
-
- Incline Bench โ†’ 45kg ร— 10 - Cable Row โ†’ 90โ€“92.5kg ร— 10 - OHT Extension โ†’ 33โ€“35kg ร— 12 - Tricep Pushdown โ†’ 40kg ร— 12 - Preacher Curl โ†’ 17.5kg ร— 12 - Hammer Curl โ†’ 12.5kg ร— 12 - EZ Bar Curl โ†’ 20kg ร— 12 - Shoulder Press โ†’ 14โ€“16kg DB ร— 10 - Plank โ†’ 60s - Wall Sit โ†’ 60s - Pull-ups โ†’ +2 reps vs Week 1 -
-
- -
-
// END OF WEEK 10 โ€” Mid-Phase 3
-
Endurance + Definition Peak
-
Phase 3's shorter rest and higher reps should give you a noticeable pump throughout sessions. Muscles will appear fuller even on rest days as capillary density increases. You'll need more water and more food to support this phase.
-
- All tri-sets completed on Fri - 21s curls without form breakdown - 45s rest maintained on arm isolation - Leg press โ†’ 10kg more than W1 - Back extension with added plate -
-
- -
-
// END OF WEEK 12 โ€” FINAL BENCHMARKS
-
12-Week Transformation Targets
-
These are your final targets. If you hit most of these, your physique will be measurably and visibly different โ€” wider shoulders, defined arms, better posture from back work, functional core. People will notice.
-
- Incline Bench โ†’ 47.5โ€“50kg ร— 10 - Cable Row โ†’ 95kg ร— 10 - Lat Pulldown โ†’ 62.5kg ร— 10 - Preacher Curl โ†’ 18โ€“20kg ร— 12 - Hammer Curl โ†’ 13.75โ€“15kg ร— 12 - EZ Bar Curl โ†’ 22.5kg ร— 12 - Shoulder Press โ†’ 17.5โ€“20kg DB ร— 10 - Lateral Raise โ†’ 12.5kg ร— 15 - OHT Extension โ†’ 35โ€“37.5kg ร— 12 - Tricep Pushdown โ†’ 42.5kg ร— 12 - Pull-ups โ†’ +3 reps vs Week 1 - Plank โ†’ 75s - Knee pain-free on daily activities -
-
- -
- -
- - -
- -

- Progressive overload is the only reason muscles grow. These are the exact rules to follow. Add weight when you hit the top of the rep range for all sets with good form. Drop back 10% if form breaks. -

- -
The Overload Rules
- -
-
-
Compound Lifts
-
- Bench, Row, Lat Pulldown, Shoulder Press

- Add weight when: You complete all sets at top of rep range (e.g. 3ร—10 all sets)

- Increment: +2.5kg per session

- If you fail mid-set: Repeat same weight next session -
-
-
-
Isolation Lifts
-
- Curls, extensions, lateral raises, pec deck

- Add weight when: All sets at top rep range, last set feels like โ‰ฅ 2 reps in reserve

- Increment: +1.25kg (microload) or +2.5kg if easy

- Alternative: Add 1 rep per set instead of weight -
-
-
-
Bodyweight / Timed
-
- Pull-ups, plank, wall sit, rehab work

- Progress by: +1 rep per week for pull-ups, +5s per week for holds

- Pull-ups plateau: Use assisted machine to go heavier on lat pulldown in parallel

- Never skip these โ€” biggest long-term gains -
-
-
- -
Per-Exercise Overload Schedule
-
- -
-
Incline Bench Press
-
-
Current (Mar 18)40kg ร— 10
-
W4 Target42.5kg ร— 10
-
W8 Target45โ€“47.5kg ร— 10
-
W12 Target50kg ร— 10
-
Increment+2.5kg / 2 sessions
-
-
- -
-
Seated Cable Row
-
-
Current85kg ร— 10
-
W4 Target87.5โ€“90kg ร— 10
-
W8 Target92.5kg ร— 10
-
W12 Target95โ€“100kg ร— 10
-
Increment+2.5kg / session
-
-
- -
-
Preacher Curl (Your Biggest Gap)
-
-
Current15kg ร— 12
-
W4 Target16โ€“16.5kg ร— 12
-
W8 Target17.5โ€“18kg ร— 12
-
W12 Target19โ€“20kg ร— 12
-
Key fix3s eccentric every rep
-
-
- -
-
Hammer Curl
-
-
Current10kg ร— 12
-
W4 Target11.25kg ร— 12
-
W8 Target12.5kg ร— 12
-
W12 Target13.75โ€“15kg ร— 12
-
Increment+1.25kg / 2 sessions
-
-
- -
-
Lateral Raise
-
-
Current10kg ร— 12
-
W4 Target10kg ร— 15 (reps first)
-
W8 Target11.25โ€“12.5kg ร— 12
-
W12 Target12.5kg ร— 15
-
Key fix3s slow eccentric
-
-
- -
-
OHT Extension + Tricep Pushdown
-
-
OHT Current30kg ร— 12
-
OHT W833โ€“35kg ร— 12
-
Pushdown Current35kg ร— 12
-
Pushdown W840kg ร— 12
-
Both W12+7.5kg on each
-
-
- -
- -
-
โš ๏ธ Deload Rule โ€” Week 5 and Week 9
-

At the start of Phase 2 (W5) and Phase 3 (W9), do a mini-deload: drop all weights by 10โ€“15% and do only 2 sets per exercise. This lets joints and tendons recover, prevents overuse injury, and you'll come back stronger the following week. This is not optional โ€” it's programmed rest. Many beginners skip this and plateau or get hurt at the 8-week mark.

-
- -
- -
- - - - diff --git a/CLAUDE.md b/CLAUDE.md index 4a1ff5d..96d4b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This file provides guidance for AI assistants working on the RepForge codebase. **Package:** `com.devasy.repforge` **Version:** `1.0.6+7` -**Flutter SDK:** `3.41.5` +**Flutter SDK:** `^3.9.2` --- @@ -217,7 +217,7 @@ dart run build_runner build --delete-conflicting-outputs Triggers automatically on push to `main`: 1. Runs `dart scripts/bump_version.dart patch` (increments patch version) 2. Commits version bump and creates a git tag (`v{version}`) -3. Builds the release APK with the Flutter version pinned in `workout-logger/pubspec.yaml` (currently 3.41.5) +3. Builds release APK with Flutter 3.38.7 4. Creates a GitHub Release with the APK attached **Do not** manually edit `pubspec.yaml` version before merging to `main` โ€” the CI handles it. For a minor/major bump, edit `pubspec.yaml` manually before the merge. diff --git a/README.md b/README.md index 6662424..c2defd0 100644 --- a/README.md +++ b/README.md @@ -1,109 +1 @@ -
-

RepForge ๐Ÿ‹๏ธ

-

Your ultimate companion to forge strength, track progress, and shatter your fitness goals.

- - -

- Flutter Badge - Dart Badge - Stars Badge - Forks Badge - Issues Badge - License Badge -

-
- ---- - -## ๐Ÿ“– About RepForge - -**RepForge** (formerly Workout-logger) is an open-source, beautifully designed workout logging mobile application built with Flutter. Whether you're a powerlifter, a casual gym-goer, or someone just starting their fitness journey, RepForge empowers you to track your workouts seamlessly, visualize your progress with detailed analytics, and stay motivated. - -Tired of subscription-heavy fitness apps? RepForge is built *by* lifters, *for* lifters. It keeps your data local, offline, and completely under your control. - -## โœจ Key Features - -- **๐Ÿ“Š Advanced Analytics:** Visualize your gains, volume, and consistency with stunning charts powered by `fl_chart`. -- **โšก Super-Fast Logging:** An intuitive UI that gets out of your way so you can focus on the pump. -- **๐Ÿ“ฑ Offline-First:** Your data is stored locally using Hive for lightning-fast, secure access anywhere, even without internet. -- **๐Ÿ”„ Export/Import:** Never lose your data. Back up and restore your workout history at your convenience. -- **๐Ÿค– Smart Insights:** Machine Learning integration for AI-powered workout insights. -- **๐ŸŽจ Custom Theming:** A modern, sleek interface with customizable themes tailored to your style. - -## ๐Ÿ“ธ Screenshots -*(Coming soon: Add screenshots of your app's dashboard, workout logger, and analytics screens here)* - ---- - -## ๐Ÿš€ Getting Started - -Want to take RepForge for a spin or contribute? Follow these steps to build the application on your local machine. - -### Prerequisites - -- [Flutter SDK](https://docs.flutter.dev/get-started/install) -- Android Studio / VS Code -- An Android/iOS device or emulator - -### Installation - -1. **Clone the repository:** - ```bash - git clone https://github.com/Devasy23/Workout-logger.git - cd Workout-logger/workout-logger - ``` - -2. **Install dependencies:** - ```bash - flutter pub get - ``` - -3. **Run the app:** - ```bash - flutter run - ``` - -### Building for Production -To build a release APK for Android: -```bash -flutter build apk --release -``` -The APK will be available in `build/app/outputs/flutter-apk/app-release.apk`. - ---- - -## ๐Ÿค Contributing to RepForge - -We believe in the power of open-source! Whether you want to fix a bug, add a feature, or improve documentation, your contributions are highly welcome and appreciated. - -### How to Contribute -1. **Fork** the repository and clone it to your local machine. -2. Create a new branch: `git checkout -b feature/your-awesome-feature`. -3. Make your changes and commit them: `git commit -m 'Add some feature'`. -4. Push to the branch: `git push origin feature/your-awesome-feature`. -5. Open a **Pull Request** and describe your changes. - -### What to work on? -Check out the **[Issues](https://github.com/Devasy23/Workout-logger/issues)** tab! If you have a new idea, feel free to open a new issue for a feature request or bug report before starting your work. Whether it's a UI tweak, performance upgrade, or a brand new workout mode, we'd love to see it! - -### Development Guidelines -- Follow standard Flutter and Dart formatting (`flutter format .`). -- Keep state management clean and modular (we use Provider). -- Ensure your PR doesn't break existing behaviors and try to add tests for new logic (`flutter test`). - ---- - -## ๐Ÿ›ก๏ธ License - -This project is a personal workout tracking application. - -## ๐Ÿ‘จโ€๐Ÿ’ป Developer - -**Devasy Patel** -- Email: patel.devasy.23@gmail.com -- GitHub: [@Devasy23](https://github.com/Devasy23) - ---- -
- Forge your strength, track your progress, achieve your goals! ๐Ÿ’ช -
\ No newline at end of file +# Workout-logger \ No newline at end of file diff --git a/docs/example_12_week_program.json b/docs/example_12_week_program.json deleted file mode 100644 index 59b7215..0000000 --- a/docs/example_12_week_program.json +++ /dev/null @@ -1,3473 +0,0 @@ -{ - "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 cc23f4f..3c62870 100644 --- a/workout-logger/lib/main.dart +++ b/workout-logger/lib/main.dart @@ -12,9 +12,7 @@ import 'services/ml_service.dart'; import 'services/interfaces/storage_service_interface.dart'; import 'services/interfaces/ml_service_interface.dart'; import 'services/workout_provider.dart'; -import 'services/settings_provider.dart'; import 'services/api_service.dart'; -import 'services/managers/program_manager.dart'; import 'theme/app_theme.dart'; import 'screens/home_screen.dart'; @@ -45,8 +43,6 @@ 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); - static final SettingsProvider _settingsProvider = SettingsProvider(_storageService); const WorkoutLoggerApp({super.key}); @@ -63,17 +59,10 @@ 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), - // SettingsProvider for user preferences (weight unit, increments) - ChangeNotifierProvider.value(value: _settingsProvider), // WorkoutProvider receives dependencies via constructor injection ChangeNotifierProvider( - create: (_) => WorkoutProvider( - _storageService, - mlService: _mlService, - programManager: _programManager, - ), + create: (_) => + WorkoutProvider(_storageService, mlService: _mlService), ), ], child: MaterialApp( @@ -108,9 +97,6 @@ class _AppInitializerState extends State { final provider = context.read(); await provider.init(); - final settings = context.read(); - await settings.init(); - // Fire-and-forget analytics in background final api = context.read(); api.sendHeartbeat(); diff --git a/workout-logger/lib/models/models.dart b/workout-logger/lib/models/models.dart index e6fb57e..c1dfe1a 100644 --- a/workout-logger/lib/models/models.dart +++ b/workout-logger/lib/models/models.dart @@ -393,357 +393,3 @@ 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/add_custom_exercise_screen.dart b/workout-logger/lib/screens/add_custom_exercise_screen.dart index ba75d83..93576f2 100644 --- a/workout-logger/lib/screens/add_custom_exercise_screen.dart +++ b/workout-logger/lib/screens/add_custom_exercise_screen.dart @@ -113,10 +113,10 @@ class _AddCustomExerciseScreenState extends State { Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppTheme.primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: AppTheme.primaryColor.withOpacity(0.3), + color: AppTheme.primaryColor.withValues(alpha: 0.3), ), ), child: Row( @@ -195,7 +195,7 @@ class _AddCustomExerciseScreenState extends State { style: ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { - return AppTheme.primaryColor.withOpacity(0.2); + return AppTheme.primaryColor.withValues(alpha: 0.2); } return AppTheme.surfaceColor; }), @@ -252,7 +252,7 @@ class _AddCustomExerciseScreenState extends State { duration: const Duration(milliseconds: 200), decoration: BoxDecoration( color: isSelected - ? muscleColor.withOpacity(0.3) + ? muscleColor.withValues(alpha: 0.3) : AppTheme.surfaceColor, borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( @@ -307,7 +307,7 @@ class _AddCustomExerciseScreenState extends State { decoration: BoxDecoration( color: AppTheme.getMuscleColor( _selectedMuscleGroup!, - ).withOpacity(0.1), + ).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Row( diff --git a/workout-logger/lib/screens/analytics_screen.dart b/workout-logger/lib/screens/analytics_screen.dart index 8794068..007b76a 100644 --- a/workout-logger/lib/screens/analytics_screen.dart +++ b/workout-logger/lib/screens/analytics_screen.dart @@ -8,7 +8,6 @@ import 'package:intl/intl.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; -import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; @@ -191,8 +190,8 @@ class _OverviewTab extends StatelessWidget { show: true, gradient: LinearGradient( colors: [ - AppTheme.primaryColor.withOpacity(0.3), - AppTheme.primaryColor.withOpacity(0.0), + AppTheme.primaryColor.withValues(alpha: 0.3), + AppTheme.primaryColor.withValues(alpha: 0.0), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -314,16 +313,13 @@ class _OverviewTab extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: weeks.entries.map((entry) { - final label = entry.key == 0 - ? 'This Week' - : '${entry.key} week${entry.key > 1 ? 's' : ''} ago'; return Column( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity( + color: AppTheme.primaryColor.withValues(alpha: entry.value > 0 ? 0.2 + (entry.value * 0.15) : 0.1 ), borderRadius: BorderRadius.circular(12), @@ -486,22 +482,12 @@ class _ExerciseProgressView extends StatelessWidget { Widget build(BuildContext context) { final progression = provider.getVolumeProgression(exerciseId); final growthModel = provider.getGrowthModel(exerciseId); - final exercise = provider.getExercise(exerciseId); - - final settings = context.watch(); - final bestOneRM = provider.getBestOneRM(exerciseId); return SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 1RM card - if (bestOneRM != null) - _buildOneRMCard(context, bestOneRM, settings), - - if (bestOneRM != null) const SizedBox(height: AppSpacing.md), - // Growth rate card if (growthModel != null) _buildGrowthCard(context, growthModel), @@ -520,85 +506,6 @@ class _ExerciseProgressView extends StatelessWidget { ); } - Widget _buildOneRMCard( - BuildContext context, - double bestOneRMkg, - SettingsProvider settings, - ) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.primaryColor.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: const Icon( - Icons.emoji_events_rounded, - color: AppTheme.primaryColor, - size: 28, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Estimated 1RM', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - Text( - settings.formatWeight(bestOneRMkg), - style: const TextStyle( - color: AppTheme.primaryColor, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - const Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Epley formula', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - Text( - 'Best across all sets', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 10, - ), - ), - ], - ), - ], - ), - ); - } - Widget _buildGrowthCard(BuildContext context, GrowthModel model) { final isGrowing = model.slope > 0; final slopeFormatted = model.slope.abs().toStringAsFixed(1); @@ -608,8 +515,8 @@ class _ExerciseProgressView extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: isGrowing - ? [AppTheme.success.withOpacity(0.2), AppTheme.success.withOpacity(0.1)] - : [AppTheme.warning.withOpacity(0.2), AppTheme.warning.withOpacity(0.1)], + ? [AppTheme.success.withValues(alpha: 0.2), AppTheme.success.withValues(alpha: 0.1)] + : [AppTheme.warning.withValues(alpha: 0.2), AppTheme.warning.withValues(alpha: 0.1)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -750,8 +657,8 @@ class _ExerciseProgressView extends StatelessWidget { show: true, gradient: LinearGradient( colors: [ - AppTheme.secondaryColor.withOpacity(0.3), - AppTheme.secondaryColor.withOpacity(0.0), + AppTheme.secondaryColor.withValues(alpha: 0.3), + AppTheme.secondaryColor.withValues(alpha: 0.0), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -785,37 +692,27 @@ class _ExerciseProgressView extends StatelessWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: AppSpacing.md), - Builder( - builder: (context) { - final settings = context.watch(); - return Column( - children: data.take(10).map((entry) { - final displayVolume = settings.toDisplay(entry.volume); - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - DateFormat('MMM d, yyyy').format(entry.date), - style: const TextStyle( - color: AppTheme.textSecondary, - ), - ), - Text( - '${displayVolume.toStringAsFixed(0)} ${settings.unitLabel}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], + ...data.take(10).map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + DateFormat('MMM d, yyyy').format(entry.date), + style: const TextStyle(color: AppTheme.textSecondary), + ), + Text( + '${entry.volume.toStringAsFixed(0)} kg', + style: const TextStyle( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, ), - ); - }).toList(), - ); - }, - ), + ), + ], + ), + ); + }), ], ), ); diff --git a/workout-logger/lib/screens/edit_workout_session_screen.dart b/workout-logger/lib/screens/edit_workout_session_screen.dart index feea401..e170139 100644 --- a/workout-logger/lib/screens/edit_workout_session_screen.dart +++ b/workout-logger/lib/screens/edit_workout_session_screen.dart @@ -816,7 +816,7 @@ class _EditableSetRowState extends State<_EditableSetRow> { width: 28, height: 28, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(14), ), child: Center( @@ -998,14 +998,14 @@ class _EditableSetRowState extends State<_EditableSetRow> { Icon( Icons.add_circle_outline, size: 14, - color: AppTheme.primaryColor.withOpacity(0.7), + color: AppTheme.primaryColor.withValues(alpha: 0.7), ), const SizedBox(width: 4), Text( 'Add Drop', style: TextStyle( fontSize: 12, - color: AppTheme.primaryColor.withOpacity(0.7), + color: AppTheme.primaryColor.withValues(alpha: 0.7), fontWeight: FontWeight.bold, ), ), @@ -1088,7 +1088,7 @@ class _EditableDropRowState extends State<_EditableDropRow> { Icon( Icons.subdirectory_arrow_right, size: 16, - color: AppTheme.textMuted.withOpacity(0.5), + color: AppTheme.textMuted.withValues(alpha: 0.5), ), const SizedBox(width: 8), @@ -1121,7 +1121,7 @@ class _EditableDropRowState extends State<_EditableDropRow> { color: AppTheme.textMuted, ), filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), + fillColor: AppTheme.surfaceColor.withValues(alpha: 0.7), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, @@ -1163,7 +1163,7 @@ class _EditableDropRowState extends State<_EditableDropRow> { color: AppTheme.textMuted, ), filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.7), + fillColor: AppTheme.surfaceColor.withValues(alpha: 0.7), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, diff --git a/workout-logger/lib/screens/exercise_library_screen.dart b/workout-logger/lib/screens/exercise_library_screen.dart index ee80ea2..8ed351e 100644 --- a/workout-logger/lib/screens/exercise_library_screen.dart +++ b/workout-logger/lib/screens/exercise_library_screen.dart @@ -71,7 +71,7 @@ class _ExerciseLibraryScreenState extends State { vertical: 4, ), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Text( @@ -144,7 +144,7 @@ class _ExerciseLibraryScreenState extends State { selected: _selectedMuscleGroup == entry.key, selectedColor: AppTheme.getMuscleColor( entry.key, - ).withOpacity(0.3), + ).withValues(alpha: 0.3), onSelected: (selected) => setState(() { _selectedMuscleGroup = selected ? entry.key : null; }), @@ -268,8 +268,8 @@ class _ExerciseCard extends StatelessWidget { height: 48, decoration: BoxDecoration( color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), + ? AppTheme.warning.withValues(alpha: 0.2) + : AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -328,8 +328,8 @@ class _ExerciseCard extends StatelessWidget { ), decoration: BoxDecoration( color: exercise.category == 'compound' - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.secondaryColor.withOpacity(0.2), + ? AppTheme.primaryColor.withValues(alpha: 0.2) + : AppTheme.secondaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -351,7 +351,7 @@ class _ExerciseCard extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), + color: AppTheme.warning.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text( @@ -436,8 +436,8 @@ class _ExerciseDetailsSheet extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: exercise.isCustom - ? AppTheme.warning.withOpacity(0.2) - : AppTheme.primaryColor.withOpacity(0.2), + ? AppTheme.warning.withValues(alpha: 0.2) + : AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Icon( @@ -493,7 +493,7 @@ class _ExerciseDetailsSheet extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), + color: AppTheme.warning.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text( @@ -584,7 +584,7 @@ class _ExerciseDetailsSheet extends StatelessWidget { Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.1), + color: AppTheme.success.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Row( diff --git a/workout-logger/lib/screens/history_screen.dart b/workout-logger/lib/screens/history_screen.dart index cbb2b57..26cb4f4 100644 --- a/workout-logger/lib/screens/history_screen.dart +++ b/workout-logger/lib/screens/history_screen.dart @@ -424,7 +424,7 @@ class _StatBox extends StatelessWidget { return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Column( @@ -499,7 +499,7 @@ class _ExerciseDetailCard extends StatelessWidget { width: 24, height: 24, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Center( @@ -534,7 +534,7 @@ class _ExerciseDetailCard extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), + color: AppTheme.warning.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text( diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 56b060d..f14e72c 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -11,7 +11,7 @@ import 'history_screen.dart'; import 'routines_screen.dart'; import 'analytics_screen.dart'; import 'exercise_library_screen.dart'; -import 'profile_screen.dart'; +import 'settings_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -33,7 +33,6 @@ class _HomeScreenState extends State { HistoryScreen(), RoutinesScreen(), AnalyticsScreen(), - ProfileScreen(), ], ), bottomNavigationBar: Container( @@ -41,7 +40,7 @@ class _HomeScreenState extends State { color: AppTheme.surfaceColor, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.3), + color: Colors.black.withValues(alpha: 0.3), blurRadius: 10, offset: const Offset(0, -2), ), @@ -49,7 +48,7 @@ class _HomeScreenState extends State { ), child: SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ @@ -57,7 +56,6 @@ class _HomeScreenState extends State { _buildNavItem(1, Icons.history_rounded, 'History'), _buildNavItem(2, Icons.list_alt_rounded, 'Routines'), _buildNavItem(3, Icons.analytics_rounded, 'Analytics'), - _buildNavItem(4, Icons.person_rounded, 'Profile'), ], ), ), @@ -74,7 +72,7 @@ class _HomeScreenState extends State { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: isSelected ? AppTheme.primaryColor.withOpacity(0.2) : Colors.transparent, + color: isSelected ? AppTheme.primaryColor.withValues(alpha: 0.2) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Column( @@ -158,13 +156,12 @@ class DashboardTab extends StatelessWidget { ), IconButton( onPressed: () { - // Navigate to Profile tab (index 4) - final homeState = context.findAncestorStateOfType<_HomeScreenState>(); - if (homeState != null) { - homeState.setState(() => homeState._currentIndex = 4); - } + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); }, - icon: const Icon(Icons.person_rounded), + icon: const Icon(Icons.settings_outlined), color: AppTheme.textPrimary, ), ], @@ -186,7 +183,7 @@ class DashboardTab extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.lg), boxShadow: [ BoxShadow( - color: AppTheme.primaryColor.withOpacity(0.4), + color: AppTheme.primaryColor.withValues(alpha: 0.4), blurRadius: 20, offset: const Offset(0, 8), ), @@ -200,7 +197,7 @@ class DashboardTab extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: const Icon( @@ -227,7 +224,7 @@ class DashboardTab extends StatelessWidget { ? 'Quick start or create a routine' : '${provider.routines.length} routines available', style: TextStyle( - color: Colors.white.withOpacity(0.8), + color: Colors.white.withValues(alpha: 0.8), fontSize: 14, ), ), @@ -472,7 +469,7 @@ class DashboardTab extends StatelessWidget { leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: const Icon( @@ -527,7 +524,7 @@ class _StatCard extends StatelessWidget { Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: color.withOpacity(0.2), + color: color.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: color, size: 20), @@ -568,7 +565,6 @@ class _RecentWorkoutCard extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.read(); final dateFormat = DateFormat('MMM d, yyyy'); final timeFormat = DateFormat('h:mm a'); @@ -585,7 +581,7 @@ class _RecentWorkoutCard extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: const Icon( diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart deleted file mode 100644 index 5bc1aa0..0000000 --- a/workout-logger/lib/screens/profile_screen.dart +++ /dev/null @@ -1,717 +0,0 @@ -// Profile Screen - User preferences, data management, and about - -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:intl/intl.dart'; - -import '../services/workout_provider.dart'; -import '../services/settings_provider.dart'; -import '../services/api_service.dart'; -import '../theme/app_theme.dart'; - -const String _appVersion = '1.0.12'; -const String _createdBy = 'Devasy Patel'; - -class ProfileScreen extends StatefulWidget { - const ProfileScreen({super.key}); - - @override - State createState() => _ProfileScreenState(); -} - -class _ProfileScreenState extends State { - bool _isExporting = false; - bool _isImporting = false; - bool _isBackingUp = false; - - // ==================== Data Actions ==================== - - Future _exportToFile() async { - setState(() => _isExporting = true); - try { - final provider = context.read(); - final jsonString = await provider.exportAllData(); - - final tempDir = await getTemporaryDirectory(); - final dateStr = DateFormat('yyyy-MM-dd_HHmmss').format(DateTime.now()); - final file = File('${tempDir.path}/repforge_backup_$dateStr.json'); - await file.writeAsString(jsonString); - - final result = await Share.shareXFiles([XFile(file.path)], - subject: 'RepForge Backup'); - - if (!mounted) return; - if (result.status == ShareResultStatus.success || - result.status == ShareResultStatus.dismissed) { - _showSnack('Backup exported successfully!', AppTheme.success); - } - } catch (e) { - if (mounted) _showSnack('Export failed. Please try again.', AppTheme.error); - } finally { - if (mounted) setState(() => _isExporting = false); - } - } - - Future _importFromFile() async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.cardColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Import Backup', - style: TextStyle(color: AppTheme.textPrimary)), - content: const Text( - 'This will merge the backup with your existing data. ' - 'Select a .json RepForge backup file to continue.', - style: TextStyle(color: AppTheme.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: - const Text('Cancel', style: TextStyle(color: AppTheme.textSecondary)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Choose File'), - ), - ], - ), - ); - if (confirmed != true) return; - - setState(() => _isImporting = true); - try { - final result = await FilePicker.platform - .pickFiles(type: FileType.custom, allowedExtensions: ['json']); - if (result == null || result.files.single.path == null) { - if (mounted) setState(() => _isImporting = false); - return; - } - - final file = File(result.files.single.path!); - final jsonString = await file.readAsString(); - final data = jsonDecode(jsonString) as Map; - if (!data.containsKey('sessions') && !data.containsKey('routines')) { - if (mounted) _showSnack('Invalid backup file.', AppTheme.error); - return; - } - - final provider = context.read(); - await provider.importData(jsonString); - - if (!mounted) return; - final sessionCount = (data['sessions'] as List?)?.length ?? 0; - final routineCount = (data['routines'] as List?)?.length ?? 0; - _showSnack( - 'Import complete! $sessionCount sessions, $routineCount routines.', - AppTheme.success); - } catch (e) { - if (mounted) _showSnack('Import failed. Invalid backup file.', AppTheme.error); - } finally { - if (mounted) setState(() => _isImporting = false); - } - } - - Future _performCloudBackup() async { - setState(() => _isBackingUp = true); - try { - final provider = context.read(); - final jsonString = await provider.exportAllData(); - final data = jsonDecode(jsonString) as Map; - - final api = context.read(); - await api.trackEvent('backup_triggered').catchError((_) => null); - final success = await api.backupData(data); - - if (!mounted) return; - _showSnack( - success ? 'Cloud backup successful!' : 'Backup failed. Please try again.', - success ? AppTheme.success : AppTheme.error, - ); - } catch (_) { - if (mounted) _showSnack('Something went wrong.', AppTheme.error); - } finally { - if (mounted) setState(() => _isBackingUp = false); - } - } - - void _showSnack(String message, Color color) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message), backgroundColor: color), - ); - } - - // ==================== Build ==================== - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - - return Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: CustomScrollView( - slivers: [ - _buildAppBar(), - SliverPadding( - padding: const EdgeInsets.all(AppSpacing.md), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildPreferencesSection(settings), - const SizedBox(height: AppSpacing.lg), - _buildDataSection(), - const SizedBox(height: AppSpacing.lg), - _buildCloudSyncSection(), - const SizedBox(height: AppSpacing.lg), - _buildAboutSection(), - const SizedBox(height: AppSpacing.xxl), - ]), - ), - ), - ], - ), - ); - } - - Widget _buildAppBar() { - return SliverAppBar( - expandedHeight: 160, - pinned: true, - backgroundColor: AppTheme.surfaceColor, - flexibleSpace: FlexibleSpaceBar( - background: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppTheme.primaryColor, Color(0xFF8B7FE8)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: const Icon( - Icons.fitness_center, - color: Colors.white, - size: 32, - ), - ), - const SizedBox(height: AppSpacing.sm), - const Text( - 'RepForge', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'v$_appVersion', - style: TextStyle( - color: Colors.white.withOpacity(0.75), - fontSize: 13, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - // ==================== Preferences ==================== - - Widget _buildPreferencesSection(SettingsProvider settings) { - return _ProfileSection( - icon: Icons.tune_rounded, - iconColor: AppTheme.primaryColor, - title: 'Preferences', - subtitle: 'Customize weight display and input steps', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SettingsLabel('Weight Unit'), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Expanded( - child: _UnitToggleButton( - label: 'kg', - selected: settings.weightUnit == WeightUnit.kg, - onTap: () => settings.setWeightUnit(WeightUnit.kg), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: _UnitToggleButton( - label: 'lbs', - selected: settings.weightUnit == WeightUnit.lbs, - onTap: () => settings.setWeightUnit(WeightUnit.lbs), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.md), - _SettingsLabel('Weight Increment'), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: 8, - runSpacing: 8, - children: settings.availableIncrements.map((inc) { - final selected = settings.weightIncrement == inc; - final label = inc == inc.truncateToDouble() - ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' - : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}'; - return ChoiceChip( - label: Text(label), - selected: selected, - onSelected: (_) { - HapticFeedback.selectionClick(); - settings.setWeightIncrement(inc); - }, - selectedColor: AppTheme.primaryColor.withOpacity(0.25), - backgroundColor: AppTheme.surfaceColor, - labelStyle: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 13, - ), - side: BorderSide( - color: selected ? AppTheme.primaryColor : Colors.transparent, - ), - padding: - const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - ); - }).toList(), - ), - ], - ), - ); - } - - // ==================== Data Management ==================== - - Widget _buildDataSection() { - return _ProfileSection( - icon: Icons.storage_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Data Management', - subtitle: 'Export, import, or backup your workout data', - child: Column( - children: [ - _ActionTile( - icon: Icons.upload_file_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Export Backup', - subtitle: 'Save a local .json backup file', - loading: _isExporting, - onTap: _isExporting ? null : _exportToFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.download_rounded, - iconColor: AppTheme.secondaryColor, - title: 'Import Backup', - subtitle: 'Merge data from a .json backup', - loading: _isImporting, - onTap: _isImporting ? null : _importFromFile, - ), - const _Divider(), - _ActionTile( - icon: Icons.cloud_upload_outlined, - iconColor: AppTheme.primaryColor, - title: 'Cloud Backup', - subtitle: 'Sync to RepForge cloud (requires account)', - loading: _isBackingUp, - onTap: _isBackingUp ? null : _performCloudBackup, - ), - ], - ), - ); - } - - // ==================== Cloud Sync (placeholder) ==================== - - Widget _buildCloudSyncSection() { - return _ProfileSection( - icon: Icons.sync_rounded, - iconColor: AppTheme.warning, - title: 'Cloud Sync', - subtitle: 'Sync your data across devices', - trailing: _ComingSoonBadge(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SettingsLabel('MongoDB Connection String'), - const SizedBox(height: AppSpacing.sm), - TextField( - enabled: false, - decoration: InputDecoration( - hintText: 'mongodb+srv://user:pass@cluster.mongodb.net/db', - hintStyle: const TextStyle( - color: AppTheme.textMuted, fontSize: 13), - prefixIcon: const Icon(Icons.link_rounded, - color: AppTheme.textMuted, size: 20), - filled: true, - fillColor: AppTheme.surfaceColor.withOpacity(0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.sm), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - ), - const SizedBox(height: AppSpacing.sm), - Text( - 'Cloud sync with custom MongoDB will be available in a future update.', - style: TextStyle( - color: AppTheme.textMuted, - fontSize: 11, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ); - } - - // ==================== About ==================== - - Widget _buildAboutSection() { - return _ProfileSection( - icon: Icons.info_outline_rounded, - iconColor: AppTheme.textSecondary, - title: 'About', - subtitle: 'RepForge Workout Logger', - child: Column( - children: [ - _InfoTile( - label: 'Version', - value: _appVersion, - icon: Icons.tag_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Created by', - value: _createdBy, - icon: Icons.person_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Platform', - value: 'Android', - icon: Icons.phone_android_rounded, - ), - const _Divider(), - _InfoTile( - label: 'Package', - value: 'com.devasy.repforge', - icon: Icons.inventory_2_outlined, - ), - ], - ), - ); - } -} - -// ==================== Reusable Widgets ==================== - -class _ProfileSection extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final Widget child; - final Widget? trailing; - - const _ProfileSection({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.child, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - Text( - subtitle, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 12, - ), - ), - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - const SizedBox(height: AppSpacing.md), - const Divider(color: AppTheme.surfaceColor, height: 1), - const SizedBox(height: AppSpacing.md), - child, - ], - ), - ); - } -} - -class _SettingsLabel extends StatelessWidget { - final String text; - const _SettingsLabel(this.text); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ); - } -} - -class _UnitToggleButton extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - - const _UnitToggleButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - HapticFeedback.selectionClick(); - onTap(); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: selected - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all( - color: selected ? AppTheme.primaryColor : Colors.transparent, - width: 1.5, - ), - ), - child: Center( - child: Text( - label, - style: TextStyle( - color: - selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - fontSize: 15, - ), - ), - ), - ), - ); - } -} - -class _ActionTile extends StatelessWidget { - final IconData icon; - final Color iconColor; - final String title; - final String subtitle; - final bool loading; - final VoidCallback? onTap; - - const _ActionTile({ - required this.icon, - required this.iconColor, - required this.title, - required this.subtitle, - required this.loading, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - title: Text( - title, - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14), - ), - subtitle: Text( - subtitle, - style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), - ), - trailing: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation(AppTheme.primaryColor), - ), - ) - : const Icon( - Icons.chevron_right, - color: AppTheme.textMuted, - ), - onTap: onTap, - ); - } -} - -class _InfoTile extends StatelessWidget { - final String label; - final String value; - final IconData icon; - - const _InfoTile({ - required this.label, - required this.value, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, color: AppTheme.textMuted, size: 18), - const SizedBox(width: 12), - Text( - label, - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - ), - ), - const Spacer(), - Text( - value, - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - - @override - Widget build(BuildContext context) { - return const Divider( - color: AppTheme.surfaceColor, - height: 1, - indent: 40, - ); - } -} - -class _ComingSoonBadge extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.15), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all( - color: AppTheme.warning.withOpacity(0.4), - ), - ), - child: const Text( - 'Coming Soon', - style: TextStyle( - color: AppTheme.warning, - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.3, - ), - ), - ); - } -} diff --git a/workout-logger/lib/screens/programs/import_program_screen.dart b/workout-logger/lib/screens/programs/import_program_screen.dart deleted file mode 100644 index 08938ed..0000000 --- a/workout-logger/lib/screens/programs/import_program_screen.dart +++ /dev/null @@ -1,393 +0,0 @@ -// 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 deleted file mode 100644 index ecf4c70..0000000 --- a/workout-logger/lib/screens/programs/program_designer_screen.dart +++ /dev/null @@ -1,1017 +0,0 @@ -// 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 deleted file mode 100644 index 3a50687..0000000 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ /dev/null @@ -1,828 +0,0 @@ -// 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 deleted file mode 100644 index 328bab1..0000000 --- a/workout-logger/lib/screens/programs/programs_screen.dart +++ /dev/null @@ -1,312 +0,0 @@ -// 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 04288e0..ebc2194 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 and training programs +// Routines Screen - Manage workout routines import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,46 +8,17 @@ 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( - backgroundColor: AppTheme.backgroundColor, + appBar: AppBar(title: const Text('Routines')), body: routines.isEmpty ? _buildEmptyState(context) : _buildRoutineList(context, routines, provider), @@ -133,7 +104,7 @@ class _RoutineCard extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: const Icon( @@ -715,7 +686,7 @@ class RoutineDetailScreen extends StatelessWidget { margin: const EdgeInsets.only(bottom: AppSpacing.sm), child: ListTile( leading: CircleAvatar( - backgroundColor: AppTheme.primaryColor.withOpacity(0.2), + backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2), child: Text( '${index + 1}', style: const TextStyle( diff --git a/workout-logger/lib/screens/settings_screen.dart b/workout-logger/lib/screens/settings_screen.dart index 3d2541a..44abf88 100644 --- a/workout-logger/lib/screens/settings_screen.dart +++ b/workout-logger/lib/screens/settings_screen.dart @@ -7,7 +7,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:intl/intl.dart'; import '../services/workout_provider.dart'; -import '../services/settings_provider.dart'; import '../services/api_service.dart'; import '../theme/app_theme.dart'; @@ -236,10 +235,6 @@ class _SettingsScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ - _buildSectionHeader('Preferences'), - const SizedBox(height: 16), - _buildPreferencesCard(), - const SizedBox(height: 24), _buildSectionHeader('Data Management'), const SizedBox(height: 16), _buildLocalBackupCard(), @@ -250,128 +245,6 @@ class _SettingsScreenState extends State { ); } - Widget _buildPreferencesCard() { - final settings = context.watch(); - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.cardColor, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.tune_rounded, - color: AppTheme.primaryColor, - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Units & Increments', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - Text( - 'Customize weight display and input steps', - style: TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - // Weight unit toggle - const Text( - 'Weight Unit', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _UnitButton( - label: 'kg', - selected: settings.weightUnit == WeightUnit.kg, - onTap: () => settings.setWeightUnit(WeightUnit.kg), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _UnitButton( - label: 'lbs', - selected: settings.weightUnit == WeightUnit.lbs, - onTap: () => settings.setWeightUnit(WeightUnit.lbs), - ), - ), - ], - ), - const SizedBox(height: 16), - // Weight increment selector - const Text( - 'Weight Increment', - style: TextStyle( - color: AppTheme.textSecondary, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: settings.availableIncrements.map((inc) { - final selected = settings.weightIncrement == inc; - return ChoiceChip( - label: Text( - inc == inc.truncateToDouble() - ? '${inc.toStringAsFixed(0)} ${settings.unitLabel}' - : '${inc.toStringAsFixed(2).replaceAll(RegExp(r'0+$'), '')} ${settings.unitLabel}', - ), - selected: selected, - onSelected: (_) => settings.setWeightIncrement(inc), - selectedColor: AppTheme.primaryColor.withOpacity(0.3), - backgroundColor: AppTheme.surfaceColor, - labelStyle: TextStyle( - color: selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: selected ? FontWeight.bold : FontWeight.normal, - ), - side: BorderSide( - color: selected - ? AppTheme.primaryColor - : AppTheme.surfaceColor, - ), - ); - }).toList(), - ), - ], - ), - ); - } - Widget _buildSectionHeader(String title) { return Text( title, @@ -397,7 +270,7 @@ class _SettingsScreenState extends State { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: AppTheme.secondaryColor.withOpacity(0.1), + color: AppTheme.secondaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon( @@ -508,7 +381,7 @@ class _SettingsScreenState extends State { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.1), + color: AppTheme.primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon( @@ -546,7 +419,6 @@ class _SettingsScreenState extends State { width: double.infinity, child: ElevatedButton( onPressed: _isBackingUp ? null : _performBackup, - style: ElevatedButton.styleFrom( backgroundColor: AppTheme.primaryColor, padding: const EdgeInsets.symmetric(vertical: 12), @@ -571,46 +443,3 @@ class _SettingsScreenState extends State { ); } } - -class _UnitButton extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - - const _UnitButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: selected - ? AppTheme.primaryColor.withOpacity(0.2) - : AppTheme.surfaceColor, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: selected ? AppTheme.primaryColor : AppTheme.surfaceColor, - width: 1.5, - ), - ), - child: Center( - child: Text( - label, - style: TextStyle( - color: selected ? AppTheme.primaryColor : AppTheme.textSecondary, - fontWeight: selected ? FontWeight.bold : FontWeight.normal, - fontSize: 15, - ), - ), - ), - ), - ); - } -} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 50cc180..5b327b4 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -7,23 +7,14 @@ import 'package:provider/provider.dart'; import '../models/models.dart'; import '../services/workout_provider.dart'; -import '../services/settings_provider.dart'; import '../theme/app_theme.dart'; import 'exercise_library_screen.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; final bool isQuickStart; - final ProgramDay? programDay; - final ProgramWeek? programWeek; - - const WorkoutFlowScreen({ - super.key, - this.routine, - this.isQuickStart = false, - this.programDay, - this.programWeek, - }); + + const WorkoutFlowScreen({super.key, this.routine, this.isQuickStart = false}); @override State createState() => _WorkoutFlowScreenState(); @@ -36,9 +27,6 @@ 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; @@ -61,72 +49,10 @@ 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.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) { + if (widget.routine != null) { provider.startWorkout(routine: widget.routine); _loadLastSessionData(); } else if (widget.isQuickStart) { @@ -137,7 +63,6 @@ class _WorkoutFlowScreenState extends State { void _loadLastSessionData() { final provider = context.read(); - final settings = context.read(); final currentExercise = provider.currentExercise; if (currentExercise == null) return; @@ -145,13 +70,10 @@ class _WorkoutFlowScreenState extends State { if (lastSession != null && lastSession.sets.isNotEmpty) { final lastSet = lastSession.sets.last; setState(() { - _currentWeight = lastSet.weight; // always stored in kg + _currentWeight = lastSet.weight; _currentReps = lastSet.reps; - // Sync controllers using display unit - final displayWeight = settings.toDisplay(_currentWeight); - _mainWeightController.text = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); + // Sync controllers with state (single source of truth) + _mainWeightController.text = _currentWeight.toString(); _mainRepsController.text = _currentReps.toString(); }); } @@ -238,9 +160,6 @@ 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( @@ -251,7 +170,7 @@ class _WorkoutFlowScreenState extends State { const SizedBox(height: AppSpacing.lg), // Weight and reps input - if (!_isDropset) _buildInputSection(provider), + if (!_isDropset) _buildInputSection(), if (!_isDropset) const SizedBox(height: AppSpacing.md), @@ -297,7 +216,7 @@ class _WorkoutFlowScreenState extends State { color: AppTheme.surfaceColor, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, 2), ), @@ -360,30 +279,27 @@ class _WorkoutFlowScreenState extends State { if (currentSetIndex >= recommendations.length) return const SizedBox(); final rec = recommendations[currentSetIndex]; - final confidenceColor = rec.confidence == 'high' - ? AppTheme.success - : (rec.confidence == 'medium' ? AppTheme.warning : AppTheme.textMuted); return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( gradient: LinearGradient( colors: [ - AppTheme.primaryColor.withOpacity(0.2), - AppTheme.secondaryColor.withOpacity(0.1), + AppTheme.primaryColor.withValues(alpha: 0.2), + AppTheme.secondaryColor.withValues(alpha: 0.1), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)), + border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.3)), ), child: Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), + color: AppTheme.primaryColor.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: const Icon( @@ -426,27 +342,16 @@ class _WorkoutFlowScreenState extends State { ); } - Widget _buildInputSection(WorkoutProvider provider) { - final settings = context.watch(); - final exerciseId = provider.currentExercise?.id ?? ''; - final isAssistedBodyweight = - exerciseId == 'pull_ups' || exerciseId == 'chin_ups'; - final weightLabel = isAssistedBodyweight - ? 'Assist (${settings.unitLabel})' - : 'Weight (${settings.unitLabel})'; - - final displayWeight = settings.toDisplay(_currentWeight); - + Widget _buildInputSection() { return Row( children: [ // Weight input Expanded( child: _buildNumberInput( - label: weightLabel, - value: displayWeight, - onChanged: (val) => - setState(() => _currentWeight = settings.toStorage(val)), - step: settings.weightIncrement, + label: 'Weight (kg)', + value: _currentWeight, + onChanged: (val) => setState(() => _currentWeight = val), + step: _currentWeight < 40 ? 2.5 : 5, decimals: 1, ), ), @@ -540,7 +445,7 @@ class _WorkoutFlowScreenState extends State { height: 40, decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppTheme.primaryColor.withOpacity(0.5)), + border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.5)), ), child: Icon(icon, color: AppTheme.primaryColor), ), @@ -614,7 +519,6 @@ class _WorkoutFlowScreenState extends State { } Widget _buildMainSetEntry() { - final settings = context.read(); // Controllers are initialized in _loadLastSessionData and updated via onChanged // No controller.text assignments in build to avoid cursor jumps return Padding( @@ -630,24 +534,22 @@ class _WorkoutFlowScreenState extends State { width: 60, child: TextFormField( controller: _mainWeightController, - decoration: InputDecoration( - hintText: settings.unitLabel, - contentPadding: const EdgeInsets.symmetric( + decoration: const InputDecoration( + hintText: 'kg', + contentPadding: EdgeInsets.symmetric( horizontal: 8, vertical: 8, ), ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), ], onChanged: (val) { final parsed = double.tryParse(val); if (parsed != null) { - // Convert from display unit to kg for storage - _currentWeight = settings.toStorage(parsed); + _currentWeight = parsed; + // Don't call setState here - controller is the source of truth during input } }, ), @@ -667,15 +569,13 @@ class _WorkoutFlowScreenState extends State { vertical: 8, ), ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), + keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (val) { final parsed = int.tryParse(val); if (parsed != null) { _currentReps = parsed; + // Don't call setState here - controller is the source of truth during input } }, ), @@ -710,25 +610,23 @@ class _WorkoutFlowScreenState extends State { width: 60, child: TextFormField( controller: _dropWeightControllers[index], - decoration: InputDecoration( - hintText: context.read().unitLabel, - contentPadding: const EdgeInsets.symmetric( + decoration: const InputDecoration( + hintText: 'kg', + contentPadding: EdgeInsets.symmetric( horizontal: 8, vertical: 8, ), ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), + keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), ], onChanged: (val) { final parsed = double.tryParse(val); if (parsed != null) { - final settings = context.read(); + // Update drop data, controller is source of truth during input _drops[index] = DropsetEntry( - weight: settings.toStorage(parsed), + weight: parsed, reps: _drops[index].reps, ); } @@ -750,14 +648,12 @@ class _WorkoutFlowScreenState extends State { vertical: 8, ), ), - keyboardType: const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), + keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (val) { final parsed = int.tryParse(val); if (parsed != null) { + // Update drop data, controller is source of truth during input _drops[index] = DropsetEntry( weight: _drops[index].weight, reps: parsed, @@ -834,7 +730,6 @@ class _WorkoutFlowScreenState extends State { } Widget _buildPreviousSets(List sets) { - final settings = context.watch(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -849,11 +744,6 @@ class _WorkoutFlowScreenState extends State { ...sets.asMap().entries.map((entry) { final index = entry.key; final set = entry.value; - final oneRM = WorkoutProvider.estimateOneRM(set.weight, set.reps); - final displayWeight = settings.toDisplay(set.weight); - final weightStr = displayWeight == displayWeight.truncateToDouble() - ? displayWeight.toStringAsFixed(0) - : displayWeight.toStringAsFixed(1); return Container( margin: const EdgeInsets.only(bottom: AppSpacing.sm), padding: const EdgeInsets.symmetric( @@ -870,7 +760,7 @@ class _WorkoutFlowScreenState extends State { width: 28, height: 28, decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.2), + color: AppTheme.success.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(14), ), child: const Icon( @@ -885,25 +775,12 @@ class _WorkoutFlowScreenState extends State { style: const TextStyle(color: AppTheme.textSecondary), ), const Spacer(), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$weightStr ${settings.unitLabel} ร— ${set.reps}', - style: const TextStyle( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (set.reps > 1) - Text( - '~${settings.formatWeight(oneRM)} 1RM', - style: const TextStyle( - color: AppTheme.textSecondary, - fontSize: 11, - ), - ), - ], + Text( + '${set.weight}kg ร— ${set.reps}', + style: const TextStyle( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + ), ), if (set.isDropset) ...[ const SizedBox(width: 8), @@ -913,7 +790,7 @@ class _WorkoutFlowScreenState extends State { vertical: 2, ), decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.2), + color: AppTheme.warning.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: const Text( @@ -982,10 +859,9 @@ class _WorkoutFlowScreenState extends State { runSpacing: 8, children: lastSession.sets.asMap().entries.map((entry) { final set = entry.value; - final settings = context.read(); return Chip( label: Text( - '${settings.formatWeight(set.weight)} ร— ${set.reps}', + '${set.weight}kg ร— ${set.reps}', style: const TextStyle(fontSize: 12), ), backgroundColor: AppTheme.surfaceColor, @@ -1009,7 +885,7 @@ class _WorkoutFlowScreenState extends State { color: AppTheme.surfaceColor, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, -2), ), @@ -1048,122 +924,6 @@ 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() { @@ -1264,9 +1024,6 @@ 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, @@ -1278,11 +1035,6 @@ 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; @@ -1298,34 +1050,8 @@ class _WorkoutFlowScreenState extends State { _drops.clear(); }); - // 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(); - } + // Start rest timer + _startRestTimer(); } void _startRestTimer() { @@ -1345,29 +1071,17 @@ 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, 600).toInt(); - _restSeconds = (_restSeconds + seconds).clamp(30, 600).toInt(); + _remainingSeconds = (_remainingSeconds + seconds).clamp(0, 300); + _restSeconds = (_restSeconds + seconds).clamp(30, 300); }); HapticFeedback.selectionClick(); } @@ -1380,7 +1094,6 @@ class _WorkoutFlowScreenState extends State { showModalBottomSheet( context: context, backgroundColor: AppTheme.cardColor, - isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -1538,7 +1251,6 @@ class _NumberPickerContent extends StatefulWidget { class _NumberPickerContentState extends State<_NumberPickerContent> { late final TextEditingController _controller; - late final FocusNode _focusNode; @override void initState() { @@ -1546,19 +1258,13 @@ class _NumberPickerContentState extends State<_NumberPickerContent> { _controller = TextEditingController( text: widget.decimals == 0 ? widget.initialValue.toInt().toString() - : widget.initialValue.toStringAsFixed(widget.decimals), + : widget.initialValue.toString(), ); - _focusNode = FocusNode(); - // Request focus after the bottom sheet is fully rendered - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); } @override void dispose() { _controller.dispose(); - _focusNode.dispose(); super.dispose(); } @@ -1572,41 +1278,23 @@ class _NumberPickerContentState extends State<_NumberPickerContent> { @override Widget build(BuildContext context) { - // Pad bottom so content shifts up above the keyboard - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg, - AppSpacing.lg + bottomInset, - ), + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _controller, - focusNode: _focusNode, - keyboardType: widget.decimals > 0 - ? const TextInputType.numberWithOptions(decimal: true) - : const TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputFormatters: widget.decimals > 0 - ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))] - : [FilteringTextInputFormatter.digitsOnly], + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')), + ], decoration: const InputDecoration(labelText: 'Enter value'), onSubmitted: (_) => _submit(), ), const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _submit, - child: const Text('Done'), - ), - ), + ElevatedButton(onPressed: _submit, child: const Text('Done')), ], ), ); diff --git a/workout-logger/lib/services/interfaces/storage_service_interface.dart b/workout-logger/lib/services/interfaces/storage_service_interface.dart index 7ad6e1e..c8eb6fc 100644 --- a/workout-logger/lib/services/interfaces/storage_service_interface.dart +++ b/workout-logger/lib/services/interfaces/storage_service_interface.dart @@ -60,13 +60,6 @@ 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 749c0d9..ebf68c3 100644 --- a/workout-logger/lib/services/managers/managers.dart +++ b/workout-logger/lib/services/managers/managers.dart @@ -15,4 +15,3 @@ 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 deleted file mode 100644 index 95cc8ce..0000000 --- a/workout-logger/lib/services/managers/program_manager.dart +++ /dev/null @@ -1,106 +0,0 @@ -// 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/settings_provider.dart b/workout-logger/lib/services/settings_provider.dart deleted file mode 100644 index 504ca63..0000000 --- a/workout-logger/lib/services/settings_provider.dart +++ /dev/null @@ -1,69 +0,0 @@ -// Settings Provider - User preferences (weight unit, increments) - -import 'package:flutter/foundation.dart'; -import 'interfaces/storage_service_interface.dart'; - -enum WeightUnit { kg, lbs } - -class SettingsProvider extends ChangeNotifier { - final IStorageService _storage; - - WeightUnit _weightUnit = WeightUnit.kg; - double _weightIncrement = 2.5; - - WeightUnit get weightUnit => _weightUnit; - double get weightIncrement => _weightIncrement; - String get unitLabel => _weightUnit == WeightUnit.kg ? 'kg' : 'lbs'; - - SettingsProvider(this._storage); - - Future init() async { - final unit = await _storage.getSetting('weightUnit'); - _weightUnit = unit == 'lbs' ? WeightUnit.lbs : WeightUnit.kg; - - final increment = await _storage.getSetting('weightIncrement'); - _weightIncrement = increment != null - ? (double.tryParse(increment) ?? _defaultIncrement) - : _defaultIncrement; - } - - double get _defaultIncrement => _weightUnit == WeightUnit.kg ? 2.5 : 5.0; - - Future setWeightUnit(WeightUnit unit) async { - _weightUnit = unit; - _weightIncrement = _defaultIncrement; - await _storage.saveSetting('weightUnit', unit == WeightUnit.kg ? 'kg' : 'lbs'); - await _storage.saveSetting('weightIncrement', _weightIncrement.toString()); - notifyListeners(); - } - - Future setWeightIncrement(double increment) async { - _weightIncrement = increment; - await _storage.saveSetting('weightIncrement', increment.toString()); - notifyListeners(); - } - - /// Convert from internal kg storage to display unit. - double toDisplay(double kg) { - if (_weightUnit == WeightUnit.lbs) return kg * 2.20462; - return kg; - } - - /// Convert from display unit back to kg for storage. - double toStorage(double display) { - if (_weightUnit == WeightUnit.lbs) return display / 2.20462; - return display; - } - - /// Format a kg weight value with the correct unit label. - String formatWeight(double kg) { - final d = toDisplay(kg); - final str = d == d.truncateToDouble() ? d.toStringAsFixed(0) : d.toStringAsFixed(1); - return '$str $unitLabel'; - } - - /// Available weight increments for the current unit. - List get availableIncrements => _weightUnit == WeightUnit.kg - ? [1.25, 2.5, 5.0, 10.0] - : [2.5, 5.0, 10.0, 25.0]; -} diff --git a/workout-logger/lib/services/storage_service.dart b/workout-logger/lib/services/storage_service.dart index 25cc76c..18ffa74 100644 --- a/workout-logger/lib/services/storage_service.dart +++ b/workout-logger/lib/services/storage_service.dart @@ -23,7 +23,6 @@ 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; @@ -31,7 +30,6 @@ class StorageService implements IStorageService { late Box _muscleGroupsBoxInstance; late Box _customExercisesBoxInstance; late Box _settingsBoxInstance; - late Box _trainingProgramsBoxInstance; String _appVersion = const String.fromEnvironment( 'APP_VERSION', @@ -65,9 +63,6 @@ class StorageService implements IStorageService { _customExercisesBox, ); _settingsBoxInstance = await Hive.openBox(_settingsBox); - _trainingProgramsBoxInstance = await Hive.openBox( - _trainingProgramsBox, - ); // Initialize default muscle groups if empty if (_muscleGroupsBoxInstance.isEmpty) { @@ -448,38 +443,6 @@ 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 d0c131f..2f970ff 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -8,7 +8,6 @@ // - 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. @@ -21,7 +20,6 @@ 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; @@ -37,8 +35,6 @@ class WorkoutProvider extends ChangeNotifier { final Map _growthModels = {}; // exerciseId -> GrowthModel - final ProgramManager programManager; - // Active workout state WorkoutSession? _activeSession; Routine? _activeRoutine; @@ -63,13 +59,9 @@ class WorkoutProvider extends ChangeNotifier { /// Create WorkoutProvider with dependency injection. /// /// Following Dependency Inversion Principle: accepts abstractions - /// 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(); + /// rather than concrete implementations. + WorkoutProvider(this._storage, {IMLService? mlService}) + : _mlService = mlService ?? MLService(); // ==================== INITIALIZATION ==================== @@ -85,7 +77,6 @@ class WorkoutProvider extends ChangeNotifier { _targets = await _storage.getAllTargets(); _muscleGroups = await _storage.getAllMuscleGroups(); _allExercises = await _storage.getAllExercises(); - await programManager.loadPrograms(); notifyListeners(); } @@ -347,14 +338,6 @@ 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 @@ -733,29 +716,6 @@ class WorkoutProvider extends ChangeNotifier { /// Get growth model for an exercise GrowthModel? getGrowthModel(String exerciseId) => _growthModels[exerciseId]; - /// Estimate 1RM using Epley formula: weight ร— (1 + reps / 30). - static double estimateOneRM(double weight, int reps) { - if (reps <= 0 || weight <= 0) return 0; - if (reps == 1) return weight; - return weight * (1 + reps / 30.0); - } - - /// Get the best estimated 1RM across all logged sets for an exercise. - /// Returns null if no sessions exist for the exercise. - double? getBestOneRM(String exerciseId) { - double? best; - for (final session in _sessions) { - for (final log in session.exercises) { - if (log.exerciseId != exerciseId) continue; - for (final set in log.sets) { - final orm = estimateOneRM(set.weight, set.reps); - if (best == null || orm > best) best = orm; - } - } - } - return best; - } - // ==================== QUICK STATS ==================== Future> getQuickStats() async { diff --git a/workout-logger/lib/theme/app_theme.dart b/workout-logger/lib/theme/app_theme.dart index e2f8694..f78ad2d 100644 --- a/workout-logger/lib/theme/app_theme.dart +++ b/workout-logger/lib/theme/app_theme.dart @@ -159,7 +159,7 @@ class AppTheme { chipTheme: ChipThemeData( backgroundColor: cardColor, - selectedColor: primaryColor.withOpacity(0.3), + selectedColor: primaryColor.withValues(alpha: 0.3), labelStyle: const TextStyle(color: textPrimary), side: BorderSide.none, shape: RoundedRectangleBorder( diff --git a/workout-logger/pubspec.yaml b/workout-logger/pubspec.yaml index ed2cfe3..a740813 100644 --- a/workout-logger/pubspec.yaml +++ b/workout-logger/pubspec.yaml @@ -16,11 +16,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.14+15 +version: 1.0.9+10 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 @@ -44,13 +43,13 @@ dependencies: provider: ^6.1.1 # Charts for visualization - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 # Utilities uuid: ^4.5.1 - intl: ^0.19.0 + intl: ^0.20.2 http: ^1.2.1 - package_info_plus: ^8.3.1 + package_info_plus: ^9.0.0 # Backup export/import file_picker: ^10.3.10 @@ -66,8 +65,8 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^5.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_lints: ^6.0.0 + flutter_launcher_icons: ^0.14.4 # Testing mockito: ^5.4.4 diff --git a/workout-logger/test/add_custom_exercise_screen_test.dart b/workout-logger/test/add_custom_exercise_screen_test.dart index c4e260e..0e509d5 100644 --- a/workout-logger/test/add_custom_exercise_screen_test.dart +++ b/workout-logger/test/add_custom_exercise_screen_test.dart @@ -5,7 +5,6 @@ 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({ @@ -25,7 +24,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); + provider = WorkoutProvider(mockStorage); await provider.init(); }); @@ -40,8 +39,6 @@ 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(); @@ -66,8 +63,6 @@ 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(); @@ -119,11 +114,13 @@ void main() { // Select a muscle group await tester.ensureVisible(find.text('Shoulders')); await tester.pumpAndSettle(); - await tester.tap(find.text('Shoulders')); - await tester.pump(); + await tester.tap(find.text('Shoulders'), warnIfMissed: false); + await tester.pumpAndSettle(); // Submit - await tester.tap(find.text('Save')); + await tester.ensureVisible(find.text('Save')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save'), warnIfMissed: false); await tester.pumpAndSettle(); // Assert - Storage should have been called diff --git a/workout-logger/test/exercise_library_screen_test.dart b/workout-logger/test/exercise_library_screen_test.dart index 3eb9da7..52b01a1 100644 --- a/workout-logger/test/exercise_library_screen_test.dart +++ b/workout-logger/test/exercise_library_screen_test.dart @@ -5,7 +5,6 @@ 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({ @@ -25,7 +24,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); + provider = WorkoutProvider(mockStorage); await provider.init(); }); @@ -126,12 +125,12 @@ void main() { testWidgets('should filter exercises by search query', (tester) async { // Arrange - Add custom exercises await provider.addCustomExercise( - name: 'Unique Bicep Curl', + name: 'Bicep Curl', category: 'isolation', primaryMuscleGroupId: 'biceps', ); await provider.addCustomExercise( - name: 'Unique Tricep Pushdown', + name: 'Tricep Pushdown', category: 'isolation', primaryMuscleGroupId: 'triceps', ); @@ -145,12 +144,12 @@ void main() { await tester.pumpAndSettle(); // Act - Enter search query - await tester.enterText(find.byType(TextField), 'Unique Bicep'); + await tester.enterText(find.byType(TextField).first, 'Bicep'); await tester.pumpAndSettle(); // Assert - Should only show matching exercise - expect(find.text('Unique Bicep Curl'), findsOneWidget); - expect(find.text('Unique Tricep Pushdown'), findsNothing); + expect(find.text('Bicep Curl'), findsWidgets); + expect(find.text('Tricep Pushdown'), findsNothing); }); testWidgets('should show muscle group filter chips', (tester) async { @@ -194,7 +193,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); + provider = WorkoutProvider(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 deleted file mode 100644 index ef3586d..0000000 --- a/workout-logger/test/program_manager_test.dart +++ /dev/null @@ -1,255 +0,0 @@ -// 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 a422ba5..ac09b6b 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -19,7 +19,6 @@ class MockStorageService implements IStorageService { final List _targets = []; final List _muscleGroups = []; final Map _settings = {}; - final List _trainingPrograms = []; bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; @@ -226,34 +225,6 @@ 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 1171d28..8bac463 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -2,7 +2,6 @@ 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() { @@ -12,7 +11,7 @@ void main() { setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); + provider = WorkoutProvider(mockStorage); await provider.init(); });