Skip to content

feat: add Training Program Planner with JSON import - #28

Closed
Devasy wants to merge 3 commits into
mainfrom
claude/training-program-planner-Dv0w1
Closed

feat: add Training Program Planner with JSON import#28
Devasy wants to merge 3 commits into
mainfrom
claude/training-program-planner-Dv0w1

Conversation

@Devasy

@Devasy Devasy commented Mar 19, 2026

Copy link
Copy Markdown
Owner

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

Summary by CodeRabbit

Release Notes

  • New Features
    • Introduced Training Programs section with a library of structured programs
    • View detailed program information including schedules, phases, milestones, and progressive overload tracking
    • Enroll in programs and track weekly progress with milestone completion functionality
    • Import custom training programs from JSON

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
@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This 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

Cohort / File(s) Summary
Training Program Models & Configuration
docs/12_week_plan_example.json, workout-logger/lib/models/models.dart
Added domain models (TrainingProgram, ProgramPhase, ProgramWorkoutDay, ProgramExercise, ProgramSetScheme, ProgressionRule, MilestoneTarget, ProgramMilestone, DeloadConfig, ProgramEnrollment) with JSON serialization; includes 12-week hypertrophy program example with phases, weekly schedules, milestones, progression rules, and deload weeks.
Storage & Persistence Layer
workout-logger/lib/services/interfaces/storage_service_interface.dart, workout-logger/lib/services/storage_service.dart
Extended IStorageService with CRUD operations for TrainingProgram and ProgramEnrollment; implemented Hive box persistence with training_programs and program_enrollments boxes; added active enrollment lookup and JSON encode/decode support.
Program Management Service
workout-logger/lib/services/managers/program_manager.dart, workout-logger/lib/services/managers/managers.dart
Added ProgramManager ChangeNotifier for program lifecycle (load, import from JSON, create, delete, enroll); manages enrollment state transitions, week progression, milestone tracking, and working weight updates; updated managers barrel export.
User Interface & Navigation
workout-logger/lib/screens/home_screen.dart, workout-logger/lib/screens/training_programs_screen.dart, workout-logger/lib/screens/program_detail_screen.dart
Added TrainingProgramsScreen for browsing programs, importing via JSON modal, and managing active enrollment with progress; added ProgramDetailScreen with tabbed layout (Overview, Schedule, Milestones, Overload) showing phases, weekly exercises, milestone tracking, and progression rules; repositioned Analytics tab in home navigation.
App Initialization
workout-logger/lib/main.dart
Integrated ProgramManager into provider dependency injection graph; added async loadPrograms() call during app initialization after WorkoutProvider.init().

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main feature addition: a Training Program Planner with JSON import capability, which is the primary objective of this PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

This custom bottom bar no longer comfortably fits five tabs on narrow devices.

Adding Programs pushes the existing intrinsic-width Row over 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 to NavigationBar/BottomNavigationBar, or wrap each item in Expanded and 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 | 🟠 Major

Add mounted guards after async operations before using context or setState().

After await provider.init() and await programManager.loadPrograms(), the widget may be disposed. The context.read<ApiService>() call at line 110 and both setState() calls (lines 122, 124) will violate use_build_context_synchronously if executed after disposal. This follows the pattern already used in settings_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

📥 Commits

Reviewing files that changed from the base of the PR and between f284e00 and a72a2ab.

📒 Files selected for processing (10)
  • docs/12_week_plan_example.json
  • workout-logger/lib/main.dart
  • workout-logger/lib/models/models.dart
  • workout-logger/lib/screens/home_screen.dart
  • workout-logger/lib/screens/program_detail_screen.dart
  • workout-logger/lib/screens/training_programs_screen.dart
  • workout-logger/lib/services/interfaces/storage_service_interface.dart
  • workout-logger/lib/services/managers/managers.dart
  • workout-logger/lib/services/managers/program_manager.dart
  • workout-logger/lib/services/storage_service.dart

Comment on lines +114 to +140
"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."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +399 to +861
/// 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>?,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +889 to +890
double get progressPercent =>
(currentWeek / 12 * 100).clamp(0, 100); // approximate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +814 to +815
fontFeatures: const [FontFeature.tabularFigures()],
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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:


🏁 Script executed:

# First, find the file and check its imports
find . -name "program_detail_screen.dart" -type f

Repository: 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.dart

Repository: 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.dart

Repository: 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.dart

Repository: 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.dart

Repository: 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.dart

Repository: 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.dart

Repository: 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.dart

Repository: 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.

Comment on lines +1144 to +1160
_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.',
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +502 to +546
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "training_programs_screen.dart" -type f

Repository: Devasy/Workout-logger

Length of output: 123


🏁 Script executed:

sed -n '502,546p' ./workout-logger/lib/screens/training_programs_screen.dart

Repository: 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 -5

Repository: Devasy/Workout-logger

Length of output: 103


🏁 Script executed:

cat ./workout-logger/analysis_options.yaml

Repository: 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 -100

Repository: 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.dart

Repository: 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 -20

Repository: 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:


🏁 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 yaml

Repository: 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.

Suggested change
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.

Comment on lines +635 to +640
// 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.',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +84 to +91
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Comment on lines +131 to +141
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +26 to +27
static const String _trainingProgramsBox = 'training_programs';
static const String _enrollmentsBox = 'program_enrollments';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@Devasy Devasy closed this Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants