feat: add Training Program Planner with JSON import - #28
Conversation
Adds full training program management to RepForge:
Models (models.dart):
- TrainingProgram, ProgramPhase, ProgramWorkoutDay, ProgramExercise
- ProgramSetScheme (per-phase sets/reps/suggestedWeight)
- ProgramMilestone, MilestoneTarget (week checkpoints)
- ProgressionRule, WeekTarget (per-exercise overload schedule)
- DeloadConfig (programmed deload weeks)
- ProgramEnrollment (user active enrollment + progress tracking)
Storage:
- Extended IStorageService with program + enrollment CRUD methods
- Implemented in StorageService using two new Hive boxes:
training_programs and program_enrollments
Service:
- New ProgramManager (ChangeNotifier) wired into main.dart
- JSON import/export, enrollment lifecycle, week advancement,
working weight tracking, milestone completion
UI:
- TrainingProgramsScreen: program list, active enrollment card,
JSON paste/import bottom sheet, start/leave/delete actions
- ProgramDetailScreen: 4-tab view:
Overview — phases, deload warnings
Schedule — day selector + per-phase exercise tables with
tempo, rest, superset markers, and coaching notes
Milestones — timeline with completion tracking
Overload — per-exercise weight targets + increment rules
- Programs tab added to HomeScreen bottom nav (index 3)
Docs:
- docs/12_week_plan_example.json — full 12-week Aesthetics +
Endurance plan importable via the in-app JSON paste dialog
https://claude.ai/code/session_016s2xc1o4EFrS3HgUWdNqqD
WalkthroughThis PR introduces structured training program management to the workout app. It adds domain models for programs, enrollments, phases, exercises, milestones, and progression rules; implements storage persistence; creates a ProgramManager service for lifecycle operations; and provides UI screens for browsing, importing, and managing programs with enrollment and progress tracking. Changes
Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
workout-logger/lib/screens/home_screen.dart (1)
54-61:⚠️ Potential issue | 🟠 MajorThis custom bottom bar no longer comfortably fits five tabs on narrow devices.
Adding
Programspushes the existing intrinsic-widthRowover the width budget on narrow phones/tablets in portrait. Because each item has 32px horizontal padding, the bottom navigation will overflow or clip at screen widths below ~500px. Consider switching toNavigationBar/BottomNavigationBar, or wrap each item inExpandedand trim the internal padding.Additionally, the
_buildNavItem()calls (lines 57–61) should use named parameters for the three arguments per the coding guidelines:_buildNavItem(index: 0, icon: Icons.home_rounded, label: 'Home').🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@workout-logger/lib/screens/home_screen.dart` around lines 54 - 61, The custom bottom bar Row currently overflows on narrow screens because five intrinsic-width children with 32px horizontal padding each exceed small widths; fix by either replacing the layout with Flutter's NavigationBar/BottomNavigationBar or make the current items flexible: wrap each _buildNavItem(...) child in Expanded (or Flexible) and reduce internal padding inside the _buildNavItem implementation so items can shrink, and update the call sites to use named parameters (e.g. _buildNavItem(index: 0, icon: Icons.home_rounded, label: 'Home')) to conform to the coding guidelines.workout-logger/lib/main.dart (1)
100-125:⚠️ Potential issue | 🟠 MajorAdd
mountedguards after async operations before usingcontextorsetState().After
await provider.init()andawait programManager.loadPrograms(), the widget may be disposed. Thecontext.read<ApiService>()call at line 110 and bothsetState()calls (lines 122, 124) will violateuse_build_context_synchronouslyif executed after disposal. This follows the pattern already used insettings_screen.dart(lines 181, 194, 209).Suggested fix
Future<void> _initializeApp() async { try { final provider = context.read<WorkoutProvider>(); await provider.init(); + if (!mounted) return; // Initialize program manager (storage already initialized above) final programManager = context.read<ProgramManager>(); await programManager.loadPrograms(); + if (!mounted) return; // Fire-and-forget analytics in background final api = context.read<ApiService>(); api.sendHeartbeat(); api.trackEvent('app_open'); provider .getQuickStats() .then((stats) { api.reportUsage(stats); }) .catchError((e) { debugPrint('Failed to report usage: $e'); }); + if (!mounted) return; setState(() => _initialized = true); } catch (e) { + if (!mounted) return; setState(() => _error = e.toString()); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@workout-logger/lib/main.dart` around lines 100 - 125, The async flow in _initializeApp uses context.read<ApiService>() and setState() after awaits which can run when the widget is disposed; after await provider.init() and after await programManager.loadPrograms() insert mounted guards (if (!mounted) return;) before calling context.read<ApiService>() and before any setState() calls, and also add a mounted check immediately before each setState(() => ...) (both success and catch blocks); ensure provider.getQuickStats() callbacks likewise check mounted before calling any context-dependent APIs or setState.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/12_week_plan_example.json`:
- Around line 114-140: The two superset partner exercises (ids ex_tpd_ss and
ex_lr_ss) use different supersetGroup labels so they won't render as a single
superset; update their supersetGroup values to match (e.g., set both to "A" or
both to "B") and ensure each phase has a matching partner entry in setSchemes
(add the missing phase_1 scheme to lateral_raise or adjust the pushdown schemes)
and adjust restSeconds for the tricep_pushdown (ex_tpd_ss) from 0 to a nonzero
value if it needs an actual rest paired with its partner; modify the
supersetGroup, setSchemes, and restSeconds fields on ex_tpd_ss and ex_lr_ss (and
repeat the same fix for the other pairs mentioned) so the UI can group them.
In `@workout-logger/lib/models/models.dart`:
- Around line 889-890: The progressPercent getter currently hard-codes a 12-week
program; update it to use the actual program duration by either converting it to
a method (e.g., progressPercentFor(int durationWeeks) or
progressPercent(TrainingProgram program)) or by computing it in a context where
both models are available, and replace the hard-coded 12 with the provided
TrainingProgram.durationWeeks (with a safe non-zero clamp/fallback to avoid
division by zero) so progressPercent uses the real duration rather than 12.
- Around line 399-861: Several program models are missing copyWith() and
ProgramEnrollment retains mutable fields; add immutable update helpers and make
state fields final. For each class shown (ProgramSetScheme, ProgramExercise,
ProgramWorkoutDay, ProgramPhase, WeekTarget, ProgressionRule, DeloadConfig,
MilestoneTarget, ProgramMilestone, TrainingProgram) implement a copyWith(...)
that accepts nullable overrides for every field and returns a new instance with
replaced values; keep all fields final and preserve existing
constructors/fromJson/toJson behavior. For ProgramEnrollment (referenced by
currentWeek, isActive, isCompleted) make those fields final (remove in-place
mutation) and add a copyWith that can update currentWeek/isActive/isCompleted so
callers perform immutable updates. Ensure JSON factories and any callers use
copyWith instead of mutating objects in place.
In `@workout-logger/lib/screens/program_detail_screen.dart`:
- Around line 1144-1160: The three hard-coded _OverloadRule rows should be
replaced with a data-driven mapping from the program's ProgressionRule data
(e.g., use the program variable available in ProgramDetailScreen or its model),
so that UI reads each rule's title, color and rule text from
program.progressionRules (or program.overloadRules) rather than constants;
update the widget tree to iterate over that list and create _OverloadRule(...)
for each entry, and add safe fallbacks/defaults if a rule is missing or the list
is empty to preserve layout.
- Around line 814-815: The file uses FontFeature.tabularFigures() (e.g., in the
text style in ProgramDetailScreen) but does not import FontFeature; add an
explicit import from dart:ui (for example import 'dart:ui' show FontFeature;) at
the top of the file so references to FontFeature.tabularFigures() compile;
update any other occurrences of FontFeature in the same file accordingly.
In `@workout-logger/lib/screens/training_programs_screen.dart`:
- Around line 635-640: The current pre-import check only verifies the top-level
"name" key (decoded from jsonDecode) but must validate the full training program
shape expected by the downstream parser (schedule, phases, milestones,
progression arrays) to avoid raw cast/null exceptions; update the import path
that uses jsonDecode/decoded to perform a structural validation (e.g., add a
helper like validateTrainingProgramJson(decoded)) that asserts decoded is a Map
and contains required keys such as "schedule" (Map or expected type), "phases"
(List), and for each phase that "milestones" is a List and for each milestone
that "progressions" is a List, and throw a clear FormatException with a
descriptive message when any check fails so users see actionable validation
errors before calling the parser.
- Around line 502-546: The switch in _handleMenuAction has non-empty case bodies
for 'start', 'leave', and 'delete' that currently fall-through; add explicit
control flow termination (e.g., break or return) at the end of each case to
satisfy Dart's switch_case_completes_normally rule. Specifically, after awaiting
manager.enrollInProgram(program.id) and showing the SnackBar in the 'start'
case, add a break/return; after awaiting manager.leaveProgram() in the 'leave'
case add a break/return; and after the delete confirmation flow finishes and
possibly awaiting manager.deleteProgram(program.id) in the 'delete' case add a
break/return so each case does not complete normally. Ensure you update the
switch in _handleMenuAction accordingly.
In `@workout-logger/lib/services/managers/program_manager.dart`:
- Around line 131-141: When marking the enrollment completed in ProgramManager
(the branch that builds _activeEnrollment =
_activeEnrollment!.copyWith(isCompleted: true, isActive: false)), clear the
in-memory active enrollment so callers don't operate on a completed program: set
_activeEnrollment to null after creating the completed copy, persist that
completed copy via _storage.saveEnrollment(...), then call notifyListeners();
leave the existing behavior for the else branch that increments currentWeek
intact. Ensure references to _activeEnrollment, copyWith, saveEnrollment,
currentWeek, isCompleted, isActive, and notifyListeners are used so the
completed record is saved but no longer returned as the active enrollment.
- Around line 84-91: The delete flow in deleteProgram (calling
_storage.deleteTrainingProgram then _storage.deleteEnrollment and mutating
_activeEnrollment) is not atomic and can leave dangling or missing enrollment
records; add transactional batch operations to the storage layer and use them
from ProgramManager: implement a storage method (e.g.,
deleteTrainingProgramWithEnrollments or runAtomicBatch) that performs both the
program deletion and any related enrollment deletions in a single atomic write,
then replace the two separate calls in deleteProgram with a single atomic call
and update _programs/_activeEnrollment after the transaction succeeds (and roll
back local state on failure). Do the analogous change for the start/enroll flow
(the logic that creates a new enrollment and deactivates the old one—e.g.,
createEnrollmentAndDeactivateOld or an atomic batch via runAtomicBatch) so
creation of the new enrollment and deactivation of the old are one atomic
operation; ensure ProgramManager uses these new storage methods and only mutates
_activeEnrollment and calls notifyListeners after a successful atomic
transaction.
In `@workout-logger/lib/services/storage_service.dart`:
- Around line 26-27: exportAllData() and importData() currently omit the new
Hive boxes _trainingProgramsBox and _enrollmentsBox so program definitions and
enrollments are lost during backup/restore; update exportAllData() to include
entries from _trainingProgramsBox and _enrollmentsBox (serialize each record the
same way other boxes are serialized), and update importData() to recognize those
box names and restore their records into Hive (handle key collisions, preserve
types, and run any necessary migration/validation logic used by existing boxes).
Locate references to exportAllData, importData, and the constants
_trainingProgramsBox and _enrollmentsBox and add them to the lists/branches at
the same places where other boxes are iterated (also update any switch/case or
map that maps box names to deserialization routines so program and enrollment
objects are reconstructed correctly). Ensure errors during import are logged and
do not crash a full restore, and add tests or a small round-trip check to
confirm exported data round-trips for training programs and enrollments.
---
Outside diff comments:
In `@workout-logger/lib/main.dart`:
- Around line 100-125: The async flow in _initializeApp uses
context.read<ApiService>() and setState() after awaits which can run when the
widget is disposed; after await provider.init() and after await
programManager.loadPrograms() insert mounted guards (if (!mounted) return;)
before calling context.read<ApiService>() and before any setState() calls, and
also add a mounted check immediately before each setState(() => ...) (both
success and catch blocks); ensure provider.getQuickStats() callbacks likewise
check mounted before calling any context-dependent APIs or setState.
In `@workout-logger/lib/screens/home_screen.dart`:
- Around line 54-61: The custom bottom bar Row currently overflows on narrow
screens because five intrinsic-width children with 32px horizontal padding each
exceed small widths; fix by either replacing the layout with Flutter's
NavigationBar/BottomNavigationBar or make the current items flexible: wrap each
_buildNavItem(...) child in Expanded (or Flexible) and reduce internal padding
inside the _buildNavItem implementation so items can shrink, and update the call
sites to use named parameters (e.g. _buildNavItem(index: 0, icon:
Icons.home_rounded, label: 'Home')) to conform to the coding guidelines.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 71eadb0f-6e98-471e-a2d8-f8faa05e2961
📒 Files selected for processing (10)
docs/12_week_plan_example.jsonworkout-logger/lib/main.dartworkout-logger/lib/models/models.dartworkout-logger/lib/screens/home_screen.dartworkout-logger/lib/screens/program_detail_screen.dartworkout-logger/lib/screens/training_programs_screen.dartworkout-logger/lib/services/interfaces/storage_service_interface.dartworkout-logger/lib/services/managers/managers.dartworkout-logger/lib/services/managers/program_manager.dartworkout-logger/lib/services/storage_service.dart
| "id": "ex_tpd_ss", | ||
| "exerciseId": "tricep_pushdown", | ||
| "exerciseName": "Tricep Pushdown (rope)", | ||
| "orderIndex": 4, | ||
| "supersetGroup": "A", | ||
| "setSchemes": [ | ||
| { "phaseId": "phase_1", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, | ||
| { "phaseId": "phase_2", "sets": 3, "minReps": 12, "maxReps": 12, "isAmrap": false }, | ||
| { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false } | ||
| ], | ||
| "tempo": "2-1-1", | ||
| "restSeconds": 0, | ||
| "notes": "SUPERSET with Lateral Raise (P2/P3). Flare elbows out at bottom." | ||
| }, | ||
| { | ||
| "id": "ex_lr_ss", | ||
| "exerciseId": "lateral_raise", | ||
| "exerciseName": "Lateral Raise (standing DB)", | ||
| "orderIndex": 5, | ||
| "supersetGroup": "B", | ||
| "setSchemes": [ | ||
| { "phaseId": "phase_2", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 10 }, | ||
| { "phaseId": "phase_3", "sets": 3, "minReps": 15, "maxReps": 15, "isAmrap": false, "suggestedWeightKg": 11.25 } | ||
| ], | ||
| "tempo": "2-1-2", | ||
| "restSeconds": 60, | ||
| "notes": "Added in Phase 2. Slight forward lean, lead with elbows not hands." |
There was a problem hiding this comment.
Use matching supersetGroup values for each paired exercise.
These pairs are described as one superset, but each partner uses a different group label (A vs B). If the UI groups by supersetGroup, none of them will render as a single superset; Monday also leaves the phase-1 pushdown at restSeconds: 0 with no partner exercise.
Also applies to: 295-323, 355-378, 396-423
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/12_week_plan_example.json` around lines 114 - 140, The two superset
partner exercises (ids ex_tpd_ss and ex_lr_ss) use different supersetGroup
labels so they won't render as a single superset; update their supersetGroup
values to match (e.g., set both to "A" or both to "B") and ensure each phase has
a matching partner entry in setSchemes (add the missing phase_1 scheme to
lateral_raise or adjust the pushdown schemes) and adjust restSeconds for the
tricep_pushdown (ex_tpd_ss) from 0 to a nonzero value if it needs an actual rest
paired with its partner; modify the supersetGroup, setSchemes, and restSeconds
fields on ex_tpd_ss and ex_lr_ss (and repeat the same fix for the other pairs
mentioned) so the UI can group them.
| /// Rep range suggestion for a specific phase and exercise | ||
| class ProgramSetScheme { | ||
| final String phaseId; | ||
| final int sets; | ||
| final int minReps; | ||
| final int maxReps; | ||
| final bool isAmrap; // "AMRAP" — as many reps as possible | ||
| final double? suggestedWeightKg; // Optional starting weight hint | ||
|
|
||
| ProgramSetScheme({ | ||
| required this.phaseId, | ||
| required this.sets, | ||
| required this.minReps, | ||
| required this.maxReps, | ||
| this.isAmrap = false, | ||
| this.suggestedWeightKg, | ||
| }); | ||
|
|
||
| String get display { | ||
| if (isAmrap) return '$sets × AMRAP'; | ||
| if (minReps == maxReps) return '$sets×$minReps'; | ||
| return '$sets×$minReps-$maxReps'; | ||
| } | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'phaseId': phaseId, | ||
| 'sets': sets, | ||
| 'minReps': minReps, | ||
| 'maxReps': maxReps, | ||
| 'isAmrap': isAmrap, | ||
| 'suggestedWeightKg': suggestedWeightKg, | ||
| }; | ||
|
|
||
| factory ProgramSetScheme.fromJson(Map<String, dynamic> json) => | ||
| ProgramSetScheme( | ||
| phaseId: json['phaseId'], | ||
| sets: json['sets'], | ||
| minReps: json['minReps'], | ||
| maxReps: json['maxReps'], | ||
| isAmrap: json['isAmrap'] ?? false, | ||
| suggestedWeightKg: (json['suggestedWeightKg'] as num?)?.toDouble(), | ||
| ); | ||
| } | ||
|
|
||
| /// An exercise slot in a program workout day | ||
| class ProgramExercise { | ||
| final String id; | ||
| final String exerciseId; // References Exercise in the library | ||
| final String exerciseName; // Stored for display even if exercise not in DB | ||
| final int orderIndex; | ||
| final String? supersetGroup; // 'A', 'B', 'C' — null means standalone | ||
| final List<ProgramSetScheme> setSchemes; // Per-phase set/rep schemes | ||
| final String tempo; // e.g. "3-1-1" (eccentric-pause-concentric) | ||
| final int restSeconds; | ||
| final String? notes; // Coaching cues / form notes | ||
|
|
||
| ProgramExercise({ | ||
| required this.id, | ||
| required this.exerciseId, | ||
| required this.exerciseName, | ||
| required this.orderIndex, | ||
| this.supersetGroup, | ||
| required this.setSchemes, | ||
| this.tempo = '2-0-1', | ||
| required this.restSeconds, | ||
| this.notes, | ||
| }); | ||
|
|
||
| /// Returns the scheme for a given phase ID, falling back to first available | ||
| ProgramSetScheme? schemeForPhase(String phaseId) { | ||
| final match = setSchemes.where((s) => s.phaseId == phaseId).firstOrNull; | ||
| return match ?? setSchemes.firstOrNull; | ||
| } | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'id': id, | ||
| 'exerciseId': exerciseId, | ||
| 'exerciseName': exerciseName, | ||
| 'orderIndex': orderIndex, | ||
| 'supersetGroup': supersetGroup, | ||
| 'setSchemes': setSchemes.map((s) => s.toJson()).toList(), | ||
| 'tempo': tempo, | ||
| 'restSeconds': restSeconds, | ||
| 'notes': notes, | ||
| }; | ||
|
|
||
| factory ProgramExercise.fromJson(Map<String, dynamic> json) => | ||
| ProgramExercise( | ||
| id: json['id'], | ||
| exerciseId: json['exerciseId'], | ||
| exerciseName: json['exerciseName'], | ||
| orderIndex: json['orderIndex'], | ||
| supersetGroup: json['supersetGroup'], | ||
| setSchemes: (json['setSchemes'] as List) | ||
| .map((s) => ProgramSetScheme.fromJson(s)) | ||
| .toList(), | ||
| tempo: json['tempo'] ?? '2-0-1', | ||
| restSeconds: json['restSeconds'], | ||
| notes: json['notes'], | ||
| ); | ||
| } | ||
|
|
||
| /// A single day in the program's weekly template | ||
| class ProgramWorkoutDay { | ||
| final String id; | ||
| final String dayOfWeek; // 'monday' … 'sunday' | ||
| final String dayType; // 'push', 'pull', 'legs', 'core', 'shoulders', 'rest', 'rehab' | ||
| final String name; // e.g. "PUSH (Chest + Triceps + Front Delt)" | ||
| final String? description; | ||
| final int estimatedDurationMinutes; | ||
| final bool isRestDay; | ||
| final List<ProgramExercise> exercises; | ||
|
|
||
| ProgramWorkoutDay({ | ||
| required this.id, | ||
| required this.dayOfWeek, | ||
| required this.dayType, | ||
| required this.name, | ||
| this.description, | ||
| this.estimatedDurationMinutes = 60, | ||
| this.isRestDay = false, | ||
| required this.exercises, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'id': id, | ||
| 'dayOfWeek': dayOfWeek, | ||
| 'dayType': dayType, | ||
| 'name': name, | ||
| 'description': description, | ||
| 'estimatedDurationMinutes': estimatedDurationMinutes, | ||
| 'isRestDay': isRestDay, | ||
| 'exercises': exercises.map((e) => e.toJson()).toList(), | ||
| }; | ||
|
|
||
| factory ProgramWorkoutDay.fromJson(Map<String, dynamic> json) => | ||
| ProgramWorkoutDay( | ||
| id: json['id'], | ||
| dayOfWeek: json['dayOfWeek'], | ||
| dayType: json['dayType'], | ||
| name: json['name'], | ||
| description: json['description'], | ||
| estimatedDurationMinutes: json['estimatedDurationMinutes'] ?? 60, | ||
| isRestDay: json['isRestDay'] ?? false, | ||
| exercises: (json['exercises'] as List) | ||
| .map((e) => ProgramExercise.fromJson(e)) | ||
| .toList(), | ||
| ); | ||
| } | ||
|
|
||
| /// A training phase (e.g. Foundation, Intensify, Peak) | ||
| class ProgramPhase { | ||
| final String id; | ||
| final String name; | ||
| final String description; | ||
| final int startWeek; | ||
| final int endWeek; | ||
| final String defaultSetsDisplay; // e.g. "3×10" | ||
| final int defaultRestSeconds; | ||
| final int rpeTarget; // 1–10 RPE | ||
| final bool isDeloadWeek; | ||
|
|
||
| ProgramPhase({ | ||
| required this.id, | ||
| required this.name, | ||
| required this.description, | ||
| required this.startWeek, | ||
| required this.endWeek, | ||
| this.defaultSetsDisplay = '3×10', | ||
| this.defaultRestSeconds = 75, | ||
| this.rpeTarget = 7, | ||
| this.isDeloadWeek = false, | ||
| }); | ||
|
|
||
| int get durationWeeks => endWeek - startWeek + 1; | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'id': id, | ||
| 'name': name, | ||
| 'description': description, | ||
| 'startWeek': startWeek, | ||
| 'endWeek': endWeek, | ||
| 'defaultSetsDisplay': defaultSetsDisplay, | ||
| 'defaultRestSeconds': defaultRestSeconds, | ||
| 'rpeTarget': rpeTarget, | ||
| 'isDeloadWeek': isDeloadWeek, | ||
| }; | ||
|
|
||
| factory ProgramPhase.fromJson(Map<String, dynamic> json) => ProgramPhase( | ||
| id: json['id'], | ||
| name: json['name'], | ||
| description: json['description'], | ||
| startWeek: json['startWeek'], | ||
| endWeek: json['endWeek'], | ||
| defaultSetsDisplay: json['defaultSetsDisplay'] ?? '3×10', | ||
| defaultRestSeconds: json['defaultRestSeconds'] ?? 75, | ||
| rpeTarget: json['rpeTarget'] ?? 7, | ||
| isDeloadWeek: json['isDeloadWeek'] ?? false, | ||
| ); | ||
| } | ||
|
|
||
| /// A specific week target for an exercise in the overload schedule | ||
| class WeekTarget { | ||
| final int weekNumber; | ||
| final double targetWeightKg; | ||
| final int targetReps; | ||
| final String? notes; | ||
|
|
||
| WeekTarget({ | ||
| required this.weekNumber, | ||
| required this.targetWeightKg, | ||
| required this.targetReps, | ||
| this.notes, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'weekNumber': weekNumber, | ||
| 'targetWeightKg': targetWeightKg, | ||
| 'targetReps': targetReps, | ||
| 'notes': notes, | ||
| }; | ||
|
|
||
| factory WeekTarget.fromJson(Map<String, dynamic> json) => WeekTarget( | ||
| weekNumber: json['weekNumber'], | ||
| targetWeightKg: (json['targetWeightKg'] as num).toDouble(), | ||
| targetReps: json['targetReps'], | ||
| notes: json['notes'], | ||
| ); | ||
| } | ||
|
|
||
| /// Progressive overload rule for a single exercise in the program | ||
| class ProgressionRule { | ||
| final String exerciseId; | ||
| final String exerciseName; | ||
| final String liftType; // 'compound', 'isolation', 'bodyweight' | ||
| final double currentWeightKg; | ||
| final double incrementKg; // Weight added per session when target reps are hit | ||
| final List<WeekTarget> weekTargets; | ||
| final String? keyNote; // e.g. "3s eccentric every rep" | ||
|
|
||
| ProgressionRule({ | ||
| required this.exerciseId, | ||
| required this.exerciseName, | ||
| required this.liftType, | ||
| required this.currentWeightKg, | ||
| required this.incrementKg, | ||
| required this.weekTargets, | ||
| this.keyNote, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'exerciseId': exerciseId, | ||
| 'exerciseName': exerciseName, | ||
| 'liftType': liftType, | ||
| 'currentWeightKg': currentWeightKg, | ||
| 'incrementKg': incrementKg, | ||
| 'weekTargets': weekTargets.map((w) => w.toJson()).toList(), | ||
| 'keyNote': keyNote, | ||
| }; | ||
|
|
||
| factory ProgressionRule.fromJson(Map<String, dynamic> json) => | ||
| ProgressionRule( | ||
| exerciseId: json['exerciseId'], | ||
| exerciseName: json['exerciseName'], | ||
| liftType: json['liftType'], | ||
| currentWeightKg: (json['currentWeightKg'] as num).toDouble(), | ||
| incrementKg: (json['incrementKg'] as num).toDouble(), | ||
| weekTargets: (json['weekTargets'] as List) | ||
| .map((w) => WeekTarget.fromJson(w)) | ||
| .toList(), | ||
| keyNote: json['keyNote'], | ||
| ); | ||
| } | ||
|
|
||
| /// Deload week configuration | ||
| class DeloadConfig { | ||
| final int weekNumber; | ||
| final double weightReductionPercent; // e.g. 0.15 for 15% reduction | ||
| final int maxSets; | ||
| final String? notes; | ||
|
|
||
| DeloadConfig({ | ||
| required this.weekNumber, | ||
| required this.weightReductionPercent, | ||
| required this.maxSets, | ||
| this.notes, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'weekNumber': weekNumber, | ||
| 'weightReductionPercent': weightReductionPercent, | ||
| 'maxSets': maxSets, | ||
| 'notes': notes, | ||
| }; | ||
|
|
||
| factory DeloadConfig.fromJson(Map<String, dynamic> json) => DeloadConfig( | ||
| weekNumber: json['weekNumber'], | ||
| weightReductionPercent: (json['weightReductionPercent'] as num).toDouble(), | ||
| maxSets: json['maxSets'], | ||
| notes: json['notes'], | ||
| ); | ||
| } | ||
|
|
||
| /// An individual target within a milestone checkpoint | ||
| class MilestoneTarget { | ||
| final String description; | ||
| final String? exerciseId; | ||
| final double? targetWeightKg; | ||
| final int? targetReps; | ||
|
|
||
| MilestoneTarget({ | ||
| required this.description, | ||
| this.exerciseId, | ||
| this.targetWeightKg, | ||
| this.targetReps, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'description': description, | ||
| 'exerciseId': exerciseId, | ||
| 'targetWeightKg': targetWeightKg, | ||
| 'targetReps': targetReps, | ||
| }; | ||
|
|
||
| factory MilestoneTarget.fromJson(Map<String, dynamic> json) => | ||
| MilestoneTarget( | ||
| description: json['description'], | ||
| exerciseId: json['exerciseId'], | ||
| targetWeightKg: (json['targetWeightKg'] as num?)?.toDouble(), | ||
| targetReps: json['targetReps'], | ||
| ); | ||
| } | ||
|
|
||
| /// A milestone checkpoint at a specific week | ||
| class ProgramMilestone { | ||
| final String id; | ||
| final int weekNumber; | ||
| final String title; | ||
| final String description; | ||
| final String phaseId; | ||
| final List<MilestoneTarget> targets; | ||
|
|
||
| ProgramMilestone({ | ||
| required this.id, | ||
| required this.weekNumber, | ||
| required this.title, | ||
| required this.description, | ||
| required this.phaseId, | ||
| required this.targets, | ||
| }); | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'id': id, | ||
| 'weekNumber': weekNumber, | ||
| 'title': title, | ||
| 'description': description, | ||
| 'phaseId': phaseId, | ||
| 'targets': targets.map((t) => t.toJson()).toList(), | ||
| }; | ||
|
|
||
| factory ProgramMilestone.fromJson(Map<String, dynamic> json) => | ||
| ProgramMilestone( | ||
| id: json['id'], | ||
| weekNumber: json['weekNumber'], | ||
| title: json['title'], | ||
| description: json['description'], | ||
| phaseId: json['phaseId'], | ||
| targets: (json['targets'] as List) | ||
| .map((t) => MilestoneTarget.fromJson(t)) | ||
| .toList(), | ||
| ); | ||
| } | ||
|
|
||
| /// Top-level training program (e.g. a 12-week plan) | ||
| class TrainingProgram { | ||
| final String id; | ||
| final String name; | ||
| final String description; | ||
| final int durationWeeks; | ||
| final int trainingDaysPerWeek; | ||
| final String? author; | ||
| final DateTime createdAt; | ||
| final bool isImported; | ||
| final List<ProgramPhase> phases; | ||
| final List<ProgramWorkoutDay> weeklySchedule; // Template: one week of days | ||
| final List<ProgramMilestone> milestones; | ||
| final List<ProgressionRule> progressionRules; | ||
| final List<DeloadConfig> deloadWeeks; | ||
| final Map<String, dynamic>? metadata; // Extra program-specific info | ||
|
|
||
| TrainingProgram({ | ||
| required this.id, | ||
| required this.name, | ||
| required this.description, | ||
| required this.durationWeeks, | ||
| required this.trainingDaysPerWeek, | ||
| this.author, | ||
| DateTime? createdAt, | ||
| this.isImported = false, | ||
| required this.phases, | ||
| required this.weeklySchedule, | ||
| required this.milestones, | ||
| required this.progressionRules, | ||
| required this.deloadWeeks, | ||
| this.metadata, | ||
| }) : createdAt = createdAt ?? DateTime.now(); | ||
|
|
||
| ProgramPhase? phaseForWeek(int weekNumber) { | ||
| for (final phase in phases) { | ||
| if (weekNumber >= phase.startWeek && weekNumber <= phase.endWeek) { | ||
| return phase; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| Map<String, dynamic> toJson() => { | ||
| 'id': id, | ||
| 'name': name, | ||
| 'description': description, | ||
| 'durationWeeks': durationWeeks, | ||
| 'trainingDaysPerWeek': trainingDaysPerWeek, | ||
| 'author': author, | ||
| 'createdAt': createdAt.toIso8601String(), | ||
| 'isImported': isImported, | ||
| 'phases': phases.map((p) => p.toJson()).toList(), | ||
| 'weeklySchedule': weeklySchedule.map((d) => d.toJson()).toList(), | ||
| 'milestones': milestones.map((m) => m.toJson()).toList(), | ||
| 'progressionRules': progressionRules.map((r) => r.toJson()).toList(), | ||
| 'deloadWeeks': deloadWeeks.map((d) => d.toJson()).toList(), | ||
| 'metadata': metadata, | ||
| }; | ||
|
|
||
| factory TrainingProgram.fromJson(Map<String, dynamic> json) => | ||
| TrainingProgram( | ||
| id: json['id'], | ||
| name: json['name'], | ||
| description: json['description'], | ||
| durationWeeks: json['durationWeeks'], | ||
| trainingDaysPerWeek: json['trainingDaysPerWeek'], | ||
| author: json['author'], | ||
| createdAt: json['createdAt'] != null | ||
| ? DateTime.parse(json['createdAt']) | ||
| : DateTime.now(), | ||
| isImported: json['isImported'] ?? false, | ||
| phases: (json['phases'] as List) | ||
| .map((p) => ProgramPhase.fromJson(p)) | ||
| .toList(), | ||
| weeklySchedule: (json['weeklySchedule'] as List) | ||
| .map((d) => ProgramWorkoutDay.fromJson(d)) | ||
| .toList(), | ||
| milestones: (json['milestones'] as List) | ||
| .map((m) => ProgramMilestone.fromJson(m)) | ||
| .toList(), | ||
| progressionRules: (json['progressionRules'] as List) | ||
| .map((r) => ProgressionRule.fromJson(r)) | ||
| .toList(), | ||
| deloadWeeks: (json['deloadWeeks'] as List) | ||
| .map((d) => DeloadConfig.fromJson(d)) | ||
| .toList(), | ||
| metadata: json['metadata'] as Map<String, dynamic>?, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Keep the new program models immutable and add copyWith() consistently.
Most of the new program definition models do not expose copyWith(), and ProgramEnrollment.currentWeek, isActive, and isCompleted are still mutable even though the class already has a copy path. That departs from the model contract used elsewhere in this file and makes in-place state mutation much easier in managers/screens.
Based on learnings, "Applies to lib/models/models.dart : All data models must implement copyWith() for immutable updates; never mutate state directly".
Also applies to: 868-870, 926-949
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/models/models.dart` around lines 399 - 861, Several
program models are missing copyWith() and ProgramEnrollment retains mutable
fields; add immutable update helpers and make state fields final. For each class
shown (ProgramSetScheme, ProgramExercise, ProgramWorkoutDay, ProgramPhase,
WeekTarget, ProgressionRule, DeloadConfig, MilestoneTarget, ProgramMilestone,
TrainingProgram) implement a copyWith(...) that accepts nullable overrides for
every field and returns a new instance with replaced values; keep all fields
final and preserve existing constructors/fromJson/toJson behavior. For
ProgramEnrollment (referenced by currentWeek, isActive, isCompleted) make those
fields final (remove in-place mutation) and add a copyWith that can update
currentWeek/isActive/isCompleted so callers perform immutable updates. Ensure
JSON factories and any callers use copyWith instead of mutating objects in
place.
| double get progressPercent => | ||
| (currentWeek / 12 * 100).clamp(0, 100); // approximate |
There was a problem hiding this comment.
progressPercent hard-codes a 12-week program.
TrainingProgram supports arbitrary durationWeeks, but this getter always divides by 12. Any non-12-week plan will report the wrong percentage. Make this a method that accepts the program duration, or compute it where both models are available.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/models/models.dart` around lines 889 - 890, The
progressPercent getter currently hard-codes a 12-week program; update it to use
the actual program duration by either converting it to a method (e.g.,
progressPercentFor(int durationWeeks) or progressPercent(TrainingProgram
program)) or by computing it in a context where both models are available, and
replace the hard-coded 12 with the provided TrainingProgram.durationWeeks (with
a safe non-zero clamp/fallback to avoid division by zero) so progressPercent
uses the real duration rather than 12.
| fontFeatures: const [FontFeature.tabularFigures()], | ||
| ), |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does Flutter's package:flutter/material.dartexportFontFeature, or do you need import 'dart:ui' show FontFeature;to useTextStyle.fontFeatures?
💡 Result:
No, package:flutter/material.dart does not export FontFeature. You need to import dart:ui (or package:flutter/painting.dart if it re-exports it, but official docs show dart:ui) to use FontFeature with TextStyle.fontFeatures. Official Flutter API docs list FontFeature in the dart:ui library, not in material or painting as a top-level export. The material library page does not list FontFeature. Examples in Flutter docs for FontFeature use import 'package:flutter/material.dart'; alone and reference FontFeature directly, implying material.dart provides access to TextStyle (from painting, re-exported via widgets), but FontFeature requires dart:ui for full access in practice, as it's a low-level UI class not re-exported by higher layers to avoid engine dependencies. TextStyle is defined in dart:ui and re-exported by package:flutter/painting.dart, which is imported by package:flutter/widgets.dart, re-exported by material.dart. However, FontFeature is specifically from dart:ui and not shown as re-exported in material or painting docs. To use FontFeature constructors like FontFeature.enable('smcp'), import dart:ui show FontFeature; is required, as confirmed by its library attribution in API docs.
Citations:
- 1: https://main-api.flutter.dev/flutter/dart-ui/FontFeature-class.html
- 2: https://api.flutter.dev/flutter/painting/TextStyle/fontFeatures.html
- 3: https://api.flutter.dev/flutter/dart-ui/FontFeature-class.html
- 4: https://api.flutter.dev/flutter/material/
- 5: https://github.com/flutter/flutter/tree/master/packages/flutter/lib/src/material
- 6: https://codebrowser.dev/flutter/flutter/packages/flutter/lib/src/material/
- 7: https://flutter.googlesource.com/mirrors/flutter.git/+/refs/heads/flutter-3.5-candidate.1/packages/flutter/lib/src/material/material.dart
- 8: https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/theme.dart
- 9: https://api.flutter.dev/flutter/dart-ui/
🏁 Script executed:
# First, find the file and check its imports
find . -name "program_detail_screen.dart" -type fRepository: Devasy/Workout-logger
Length of output: 120
🏁 Script executed:
# Once located, check the imports at the top of the file
head -50 ./lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 164
🏁 Script executed:
# Check lines around 814-815 for FontFeature usage
sed -n '810,820p' ./lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 148
🏁 Script executed:
# Check lines around 1396 for FontFeature usage
sed -n '1390,1400p' ./lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 148
🏁 Script executed:
# Check the imports at the top of the file
head -50 ./workout-logger/lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 1560
🏁 Script executed:
# Check lines around 814-815 for FontFeature usage
sed -n '810,820p' ./workout-logger/lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 476
🏁 Script executed:
# Check lines around 1390-1400 for FontFeature usage
sed -n '1390,1400p' ./workout-logger/lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 352
🏁 Script executed:
# Check if FontFeature is imported anywhere in the file
grep -n "FontFeature\|import.*dart:ui\|import.*painting" ./workout-logger/lib/screens/program_detail_screen.dartRepository: Devasy/Workout-logger
Length of output: 215
Import FontFeature from dart:ui.
Lines 814 and 1396 use FontFeature.tabularFigures(), but this file does not import it. package:flutter/material.dart does not export FontFeature—it must be imported explicitly from dart:ui. Add import 'dart:ui' show FontFeature; or the code will not compile.
Suggested fix
+import 'dart:ui' show FontFeature;
+
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/screens/program_detail_screen.dart` around lines 814 -
815, The file uses FontFeature.tabularFigures() (e.g., in the text style in
ProgramDetailScreen) but does not import FontFeature; add an explicit import
from dart:ui (for example import 'dart:ui' show FontFeature;) at the top of the
file so references to FontFeature.tabularFigures() compile; update any other
occurrences of FontFeature in the same file accordingly.
| _OverloadRule( | ||
| color: const Color(0xFFC8491A), | ||
| title: 'Compound Lifts', | ||
| rule: 'Add +2.5kg when all sets at top of rep range.', | ||
| ), | ||
| const SizedBox(height: 8), | ||
| _OverloadRule( | ||
| color: const Color(0xFF1A6BC8), | ||
| title: 'Isolation Lifts', | ||
| rule: 'Add +1.25kg or +1 rep. Strict form required.', | ||
| ), | ||
| const SizedBox(height: 8), | ||
| _OverloadRule( | ||
| color: const Color(0xFF1A8C4E), | ||
| title: 'Bodyweight / Timed', | ||
| rule: '+1 rep/week for pull-ups, +5s/week for holds.', | ||
| ), |
There was a problem hiding this comment.
Make the overload summary data-driven.
These three _OverloadRule rows are hard-coded, so any imported program whose ProgressionRule values differ from +2.5kg / +1.25kg / +1 rep will show incorrect guidance in the detail screen.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/screens/program_detail_screen.dart` around lines 1144 -
1160, The three hard-coded _OverloadRule rows should be replaced with a
data-driven mapping from the program's ProgressionRule data (e.g., use the
program variable available in ProgramDetailScreen or its model), so that UI
reads each rule's title, color and rule text from program.progressionRules (or
program.overloadRules) rather than constants; update the widget tree to iterate
over that list and create _OverloadRule(...) for each entry, and add safe
fallbacks/defaults if a rule is missing or the list is empty to preserve layout.
| Future<void> _handleMenuAction( | ||
| BuildContext context, | ||
| String action, | ||
| ProgramManager manager, | ||
| ) async { | ||
| switch (action) { | ||
| case 'start': | ||
| await manager.enrollInProgram(program.id); | ||
| if (context.mounted) { | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar( | ||
| content: Text('Started "${program.name}"'), | ||
| backgroundColor: AppTheme.primaryColor, | ||
| ), | ||
| ); | ||
| } | ||
| case 'leave': | ||
| await manager.leaveProgram(); | ||
| case 'delete': | ||
| final confirm = await showDialog<bool>( | ||
| context: context, | ||
| builder: (_) => AlertDialog( | ||
| backgroundColor: AppTheme.surfaceColor, | ||
| title: const Text('Delete Program?'), | ||
| content: Text('Remove "${program.name}" permanently?'), | ||
| actions: [ | ||
| TextButton( | ||
| onPressed: () => Navigator.pop(context, false), | ||
| child: const Text('Cancel'), | ||
| ), | ||
| ElevatedButton( | ||
| style: ElevatedButton.styleFrom( | ||
| backgroundColor: Colors.red, | ||
| foregroundColor: Colors.white, | ||
| ), | ||
| onPressed: () => Navigator.pop(context, true), | ||
| child: const Text('Delete'), | ||
| ), | ||
| ], | ||
| ), | ||
| ); | ||
| if (confirm == true && context.mounted) { | ||
| await manager.deleteProgram(program.id); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "training_programs_screen.dart" -type fRepository: Devasy/Workout-logger
Length of output: 123
🏁 Script executed:
sed -n '502,546p' ./workout-logger/lib/screens/training_programs_screen.dartRepository: Devasy/Workout-logger
Length of output: 1547
🏁 Script executed:
# Check if there's an analysis_options.yaml and what rules it enforces
find . -name "analysis_options.yaml" -type f | head -5Repository: Devasy/Workout-logger
Length of output: 103
🏁 Script executed:
cat ./workout-logger/analysis_options.yamlRepository: Devasy/Workout-logger
Length of output: 1484
🏁 Script executed:
# Check the Dart version and Flutter version to understand which Dart rules apply
cat ./workout-logger/pubspec.yaml | grep -A 5 "environment:"Repository: Devasy/Workout-logger
Length of output: 322
🏁 Script executed:
# Let's verify the Dart switch statement rules by checking flutter_lints documentation
# or by looking for similar patterns in the codebase
rg "switch\s*\(" ./workout-logger/lib --type dart -A 10 | head -100Repository: Devasy/Workout-logger
Length of output: 3841
🏁 Script executed:
# Get more context - check if there are break or return statements after the visible snippet
sed -n '500,560p' ./workout-logger/lib/screens/training_programs_screen.dartRepository: Devasy/Workout-logger
Length of output: 1833
🏁 Script executed:
# Let's check the exact Dart version requirement and verify the switch statement compilation rules
# by looking at flutter_lints rules for switch statements
web_search "Dart 3.9 switch statement non-empty case body termination requirement"Repository: Devasy/Workout-logger
Length of output: 115
🏁 Script executed:
# Let's search for any related lint rules in flutter_lints
rg "case_expressions_required|missing_return|fall_through" ./workout-logger --type yaml --type dart | head -20Repository: Devasy/Workout-logger
Length of output: 47
🌐 Web query:
Dart switch statement non-empty case body must end with break return throw or continue
💡 Result:
Yes, in Dart, a non-empty case body in a switch statement must end with a break, return, throw, or continue statement. This is a compile-time requirement enforced by the analyzer to prevent unintended fall-through to the next case. This rule ensures explicit control flow. Non-empty cases automatically "jump to the end of the switch" after completion (no explicit break needed at the end), but if the body doesn't terminate control flow, it produces a switch_case_completes_normally diagnostic error. Empty cases fall through to the next case by design, allowing shared bodies. This behavior is current as of Dart 3.11 (docs published up to 2025-02). Switch expressions have different rules (exhaustive, expression-only bodies with =>).
Citations:
- 1: https://dart.dev/tools/diagnostics/switch_case_completes_normally
- 2: https://dart.dev/language/branches
- 3: https://stackoverflow.com/questions/76746033/what-is-the-benefit-of-break-in-switch-statement-dart-language
- 4: Remove required break keyword from switch/case dart-lang/sdk#2047
🏁 Script executed:
# Let's verify the exact behavior of Dart switch statements by looking at Dart language documentation
# Check if the code as written would actually compile or produce an error
rg "switch_case_completes_normally" ./workout-logger --type dart --type yamlRepository: Devasy/Workout-logger
Length of output: 47
Add explicit termination to each non-empty switch case.
Cases start, leave, and delete contain non-empty bodies without explicit control flow termination (return, break, throw, or continue). Dart 3.9+ enforces this as a compile-time requirement and will produce a switch_case_completes_normally error, preventing the code from compiling.
Suggested fix
Future<void> _handleMenuAction(
BuildContext context,
String action,
ProgramManager manager,
) async {
switch (action) {
case 'start':
await manager.enrollInProgram(program.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Started "${program.name}"'),
backgroundColor: AppTheme.primaryColor,
),
);
}
+ return;
case 'leave':
await manager.leaveProgram();
+ return;
case 'delete':
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: AppTheme.surfaceColor,
title: const Text('Delete Program?'),
content: Text('Remove "${program.name}" permanently?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true && context.mounted) {
await manager.deleteProgram(program.id);
}
+ return;
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Future<void> _handleMenuAction( | |
| BuildContext context, | |
| String action, | |
| ProgramManager manager, | |
| ) async { | |
| switch (action) { | |
| case 'start': | |
| await manager.enrollInProgram(program.id); | |
| if (context.mounted) { | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| SnackBar( | |
| content: Text('Started "${program.name}"'), | |
| backgroundColor: AppTheme.primaryColor, | |
| ), | |
| ); | |
| } | |
| case 'leave': | |
| await manager.leaveProgram(); | |
| case 'delete': | |
| final confirm = await showDialog<bool>( | |
| context: context, | |
| builder: (_) => AlertDialog( | |
| backgroundColor: AppTheme.surfaceColor, | |
| title: const Text('Delete Program?'), | |
| content: Text('Remove "${program.name}" permanently?'), | |
| actions: [ | |
| TextButton( | |
| onPressed: () => Navigator.pop(context, false), | |
| child: const Text('Cancel'), | |
| ), | |
| ElevatedButton( | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: Colors.red, | |
| foregroundColor: Colors.white, | |
| ), | |
| onPressed: () => Navigator.pop(context, true), | |
| child: const Text('Delete'), | |
| ), | |
| ], | |
| ), | |
| ); | |
| if (confirm == true && context.mounted) { | |
| await manager.deleteProgram(program.id); | |
| } | |
| } | |
| Future<void> _handleMenuAction( | |
| BuildContext context, | |
| String action, | |
| ProgramManager manager, | |
| ) async { | |
| switch (action) { | |
| case 'start': | |
| await manager.enrollInProgram(program.id); | |
| if (context.mounted) { | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| SnackBar( | |
| content: Text('Started "${program.name}"'), | |
| backgroundColor: AppTheme.primaryColor, | |
| ), | |
| ); | |
| } | |
| return; | |
| case 'leave': | |
| await manager.leaveProgram(); | |
| return; | |
| case 'delete': | |
| final confirm = await showDialog<bool>( | |
| context: context, | |
| builder: (_) => AlertDialog( | |
| backgroundColor: AppTheme.surfaceColor, | |
| title: const Text('Delete Program?'), | |
| content: Text('Remove "${program.name}" permanently?'), | |
| actions: [ | |
| TextButton( | |
| onPressed: () => Navigator.pop(context, false), | |
| child: const Text('Cancel'), | |
| ), | |
| ElevatedButton( | |
| style: ElevatedButton.styleFrom( | |
| backgroundColor: Colors.red, | |
| foregroundColor: Colors.white, | |
| ), | |
| onPressed: () => Navigator.pop(context, true), | |
| child: const Text('Delete'), | |
| ), | |
| ], | |
| ), | |
| ); | |
| if (confirm == true && context.mounted) { | |
| await manager.deleteProgram(program.id); | |
| } | |
| return; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/screens/training_programs_screen.dart` around lines 502 -
546, The switch in _handleMenuAction has non-empty case bodies for 'start',
'leave', and 'delete' that currently fall-through; add explicit control flow
termination (e.g., break or return) at the end of each case to satisfy Dart's
switch_case_completes_normally rule. Specifically, after awaiting
manager.enrollInProgram(program.id) and showing the SnackBar in the 'start'
case, add a break/return; after awaiting manager.leaveProgram() in the 'leave'
case add a break/return; and after the delete confirmation flow finishes and
possibly awaiting manager.deleteProgram(program.id) in the 'delete' case add a
break/return so each case does not complete normally. Ensure you update the
switch in _handleMenuAction accordingly.
| // Validate JSON structure before importing | ||
| final decoded = jsonDecode(text); | ||
| if (decoded is! Map || !decoded.containsKey('name')) { | ||
| throw const FormatException( | ||
| 'Invalid format. Must be a training program JSON with a "name" field.', | ||
| ); |
There was a problem hiding this comment.
The pre-import validation is much weaker than the parser contract.
Checking only for "name" lets malformed payloads through even though the downstream program parser requires the schedule, phase, milestone, and progression structure as well. The fallback error shown to users will then be a raw cast/null exception instead of an actionable validation message.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/screens/training_programs_screen.dart` around lines 635 -
640, The current pre-import check only verifies the top-level "name" key
(decoded from jsonDecode) but must validate the full training program shape
expected by the downstream parser (schedule, phases, milestones, progression
arrays) to avoid raw cast/null exceptions; update the import path that uses
jsonDecode/decoded to perform a structural validation (e.g., add a helper like
validateTrainingProgramJson(decoded)) that asserts decoded is a Map and contains
required keys such as "schedule" (Map or expected type), "phases" (List), and
for each phase that "milestones" is a List and for each milestone that
"progressions" is a List, and throw a clear FormatException with a descriptive
message when any check fails so users see actionable validation errors before
calling the parser.
| Future<void> deleteProgram(String id) async { | ||
| await _storage.deleteTrainingProgram(id); | ||
| _programs.removeWhere((p) => p.id == id); | ||
| if (_activeEnrollment?.programId == id) { | ||
| await _storage.deleteEnrollment(_activeEnrollment!.id); | ||
| _activeEnrollment = null; | ||
| } | ||
| notifyListeners(); |
There was a problem hiding this comment.
Make these program/enrollment transitions atomic.
Line 85 and Line 88 are two unrelated writes, so a failure in the second step can leave an active enrollment pointing at a deleted program. Lines 97-109 have the inverse problem: a double tap on “Start” can create two active enrollments, and a failure after deactivating the old record can leave none. This is persistent because workout-logger/lib/services/storage_service.dart:489-494 saves each enrollment with a plain put, and workout-logger/lib/services/storage_service.dart:505-513 later returns the first active enrollment it finds.
Also applies to: 95-110
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/services/managers/program_manager.dart` around lines 84 -
91, The delete flow in deleteProgram (calling _storage.deleteTrainingProgram
then _storage.deleteEnrollment and mutating _activeEnrollment) is not atomic and
can leave dangling or missing enrollment records; add transactional batch
operations to the storage layer and use them from ProgramManager: implement a
storage method (e.g., deleteTrainingProgramWithEnrollments or runAtomicBatch)
that performs both the program deletion and any related enrollment deletions in
a single atomic write, then replace the two separate calls in deleteProgram with
a single atomic call and update _programs/_activeEnrollment after the
transaction succeeds (and roll back local state on failure). Do the analogous
change for the start/enroll flow (the logic that creates a new enrollment and
deactivates the old one—e.g., createEnrollmentAndDeactivateOld or an atomic
batch via runAtomicBatch) so creation of the new enrollment and deactivation of
the old are one atomic operation; ensure ProgramManager uses these new storage
methods and only mutates _activeEnrollment and calls notifyListeners after a
successful atomic transaction.
| final nextWeek = _activeEnrollment!.currentWeek + 1; | ||
| if (nextWeek > program.durationWeeks) { | ||
| _activeEnrollment = _activeEnrollment!.copyWith( | ||
| isCompleted: true, | ||
| isActive: false, | ||
| ); | ||
| } else { | ||
| _activeEnrollment = _activeEnrollment!.copyWith(currentWeek: nextWeek); | ||
| } | ||
| await _storage.saveEnrollment(_activeEnrollment!); | ||
| notifyListeners(); |
There was a problem hiding this comment.
Don’t keep a completed enrollment in activeEnrollment.
This branch marks the record isActive: false but still leaves it in _activeEnrollment. After the final week, callers like currentPhase, completeMilestone(), and updateWorkingWeight() can still operate on a completed program until the next reload.
Suggested fix
final nextWeek = _activeEnrollment!.currentWeek + 1;
if (nextWeek > program.durationWeeks) {
- _activeEnrollment = _activeEnrollment!.copyWith(
+ final completedEnrollment = _activeEnrollment!.copyWith(
isCompleted: true,
isActive: false,
);
+ await _storage.saveEnrollment(completedEnrollment);
+ _activeEnrollment = null;
} else {
_activeEnrollment = _activeEnrollment!.copyWith(currentWeek: nextWeek);
+ await _storage.saveEnrollment(_activeEnrollment!);
}
- await _storage.saveEnrollment(_activeEnrollment!);
notifyListeners();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/services/managers/program_manager.dart` around lines 131 -
141, When marking the enrollment completed in ProgramManager (the branch that
builds _activeEnrollment = _activeEnrollment!.copyWith(isCompleted: true,
isActive: false)), clear the in-memory active enrollment so callers don't
operate on a completed program: set _activeEnrollment to null after creating the
completed copy, persist that completed copy via _storage.saveEnrollment(...),
then call notifyListeners(); leave the existing behavior for the else branch
that increments currentWeek intact. Ensure references to _activeEnrollment,
copyWith, saveEnrollment, currentWeek, isCompleted, isActive, and
notifyListeners are used so the completed record is saved but no longer returned
as the active enrollment.
| static const String _trainingProgramsBox = 'training_programs'; | ||
| static const String _enrollmentsBox = 'program_enrollments'; |
There was a problem hiding this comment.
Program data is missing from backup/restore.
The new training_programs and program_enrollments boxes are stored in Hive, but exportAllData() / importData() never serialize them. Users who migrate or restore app data will silently lose imported programs and enrollment progress.
Also applies to: 35-36, 70-73, 456-518
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@workout-logger/lib/services/storage_service.dart` around lines 26 - 27,
exportAllData() and importData() currently omit the new Hive boxes
_trainingProgramsBox and _enrollmentsBox so program definitions and enrollments
are lost during backup/restore; update exportAllData() to include entries from
_trainingProgramsBox and _enrollmentsBox (serialize each record the same way
other boxes are serialized), and update importData() to recognize those box
names and restore their records into Hive (handle key collisions, preserve
types, and run any necessary migration/validation logic used by existing boxes).
Locate references to exportAllData, importData, and the constants
_trainingProgramsBox and _enrollmentsBox and add them to the lists/branches at
the same places where other boxes are iterated (also update any switch/case or
map that maps box names to deserialization routines so program and enrollment
objects are reconstructed correctly). Ensure errors during import are logged and
do not crash a full restore, and add tests or a small round-trip check to
confirm exported data round-trips for training programs and enrollments.
Adds full training program management to RepForge:
Models (models.dart):
Storage:
training_programs and program_enrollments
Service:
working weight tracking, milestone completion
UI:
JSON paste/import bottom sheet, start/leave/delete actions
Overview — phases, deload warnings
Schedule — day selector + per-phase exercise tables with
tempo, rest, superset markers, and coaching notes
Milestones — timeline with completion tracking
Overload — per-exercise weight targets + increment rules
Docs:
Endurance plan importable via the in-app JSON paste dialog
https://claude.ai/code/session_016s2xc1o4EFrS3HgUWdNqqD
Summary by CodeRabbit
Release Notes