Skip to content

Feat/active workout protection - #39

Merged
Devasy merged 3 commits into
mainfrom
feat/active-workout-protection
Apr 26, 2026
Merged

Feat/active workout protection#39
Devasy merged 3 commits into
mainfrom
feat/active-workout-protection

Conversation

@Devasy

@Devasy Devasy commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Workout conflict dialog with resume/discard/cancel options
    • Safer start flow for quick-start, routines, and program days—navigation occurs only after start/resume
    • Automatic draft saving and restore of in-progress workouts
    • System back button now shows confirmation when leaving a workout
    • Recommendation weights respect user unit settings
  • Bug Fixes

    • “See All” now correctly switches to History tab
  • Tests

    • Added/updated tests covering draft persistence and conflict handling

Devasy and others added 2 commits April 26, 2026 01:02
- Added a new WorkoutConflictDialog to manage conflicts when starting a workout.
- Refactored workout starting logic in ProgramDetailScreen and RoutinesScreen to handle conflicts.
- Introduced draft persistence for active workouts in WorkoutProvider, allowing restoration of workouts after app closure.
- Enhanced workout flow management with options to discard or resume workouts.
- Updated tests to cover new draft persistence and conflict handling features.

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds active-workout draft persistence and single-workout enforcement to the provider; exposes a conflict-resolution API (StartWorkoutConflictAction + startWorkoutSafely) and a dialog UI. Multiple screens invoke the safe start flow with guarded navigation. Tests and a mock storage helper are extended to validate persistence and conflict behavior.

Changes

Cohort / File(s) Summary
Conflict Dialog
workout-logger/lib/screens/widgets/workout_conflict_dialog.dart
New WorkoutConflictDialog widget and showWorkoutConflictDialog() returning StartWorkoutConflictAction values (resume, discardAndStart, cancel) with formatted start-time display.
Workout Provider Core
workout-logger/lib/services/workout_provider.dart
Adds StartWorkoutConflictAction enum and WorkoutInProgressError; implements draft persistence (serialize/restore active workout), single-workout semantics (startWorkout throws if active), startWorkoutSafely(...) conflict resolver, async cancelWorkout(), and activeProgramDay/activeProgramWeek fields.
Screen Integrations
workout-logger/lib/screens/home_screen.dart, workout-logger/lib/screens/routines_screen.dart, workout-logger/lib/screens/programs/program_detail_screen.dart
Replace direct navigation starts with conflict-aware startWorkoutSafely flows that show the conflict dialog and only navigate to WorkoutFlowScreen when started or resume; minor formatting/layout refactors.
Workout Flow
workout-logger/lib/screens/workout_flow_screen.dart
Resume-from-draft initialization, prevent uncontrolled back pops with leave-workout dialog, await cancelWorkout() before restart, thread provider into slot/deload logic, and show weight unit via settings.
Tests & Mocks
workout-logger/test/workout_provider_test.dart, workout-logger/test/test_utils/mock_storage_service.dart, workout-logger/test/program_detail_screen_test.dart, workout-logger/test/performance_regression_test.dart
Adds tests for draft persistence, init restoration, and conflict behavior; extends MockStorageService with settings getter, saveSettingCallCount, and configurable save delays; removes one unused import.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/active workout protection' directly aligns with the main objective of the pull request, which introduces conflict detection and resolution when starting workouts while one is already in progress.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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


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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@workout-logger/lib/screens/programs/program_detail_screen.dart`:
- Around line 106-110: When handling the branch that currently does
Navigator.push(... builder: (_) => WorkoutFlowScreen(programDay: day,
programWeek: week)) for started || conflictAction ==
StartWorkoutConflictAction.resume, stop using the newly tapped day/week and
instead pass the active workout’s original program context (e.g. use
activeWorkout.programDay and activeWorkout.programWeek or the equivalent fields
from your workout state/store) into WorkoutFlowScreen so the resumed route uses
the existing workout’s structure; null-check the active workout and fall back to
the tapped day/week only if no active workout exists.

In `@workout-logger/lib/services/workout_provider.dart`:
- Around line 145-153: The draft JSON saved in workout_provider (built with
_draftSchemaVersion, _workoutStartTime, _activeRoutine?.id,
_currentExerciseIndex, and _currentExerciseLogs) lacks program-specific context
so WorkoutFlowScreen cannot rebuild deload targets, rest timings, supersets or
slot lookups from programDay/programWeek when resuming; update the persisted
draft to include the workout origin/slot metadata (e.g.,
programId/programWeek/programDay/slotId or a serialized origin enum) or
alternatively move the programDay/programWeek/slot state into the provider so it
is always serialized; ensure both the creation site that builds the draft and
the restore logic that reads it handle the new fields (also apply the same
change at the other draft persistence area noted around lines 212-231).
- Around line 135-160: _async draft saves from _persistDraft() can complete
after callers use unawaited(...) and then call finishWorkout() or
cancelWorkout() which calls _clearDraft(), causing a stale save to recreate a
cleared draft; fix by serializing/awaiting pending draft writes: introduce a
single tracking Future or version counter (e.g., _lastDraftWrite or
_draftWriteVersion) updated by _persistDraft() and awaited by _clearDraft() (and
by finishWorkout()/cancelWorkout() before they clear _draftKey), or have
_persistDraft() check the current version before writing to _storage.saveSetting
to avoid writing stale drafts; update references in addSet(), nextExercise(),
finishWorkout(), cancelWorkout(), _persistDraft(), _clearDraft() to use this
mechanism.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c622dce7-ae4b-4779-9ceb-c626cac2c31c

📥 Commits

Reviewing files that changed from the base of the PR and between bade4c6 and f1256c6.

📒 Files selected for processing (9)
  • workout-logger/lib/screens/home_screen.dart
  • workout-logger/lib/screens/programs/program_detail_screen.dart
  • workout-logger/lib/screens/routines_screen.dart
  • workout-logger/lib/screens/widgets/workout_conflict_dialog.dart
  • workout-logger/lib/screens/workout_flow_screen.dart
  • workout-logger/lib/services/workout_provider.dart
  • workout-logger/test/performance_regression_test.dart
  • workout-logger/test/test_utils/mock_storage_service.dart
  • workout-logger/test/workout_provider_test.dart
💤 Files with no reviewable changes (1)
  • workout-logger/test/performance_regression_test.dart

Comment thread workout-logger/lib/screens/programs/program_detail_screen.dart Outdated
Comment thread workout-logger/lib/services/workout_provider.dart
Comment thread workout-logger/lib/services/workout_provider.dart

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
workout-logger/lib/screens/workout_flow_screen.dart (1)

218-235: ⚠️ Potential issue | 🟡 Minor

System-back bypasses the leave/cancel flow when in the exercise selector.

_buildExerciseSelector() (line 220) returns a plain Scaffold without PopScope, so when currentExerciseLogs.isEmpty (quick-start, no exercises picked yet) a hardware/gesture back silently pops the route while the provider still has hasActiveWorkout == true and a persisted (empty) draft. The explicit close button on this view goes through _showCancelDialog — the back gesture should be consistent. After re-launch the empty draft restores and the user re-enters the selector with the same dangling state.

🛡️ Suggested fix — wrap the selector in the same PopScope
   Widget _buildExerciseSelector() {
-    return Scaffold(
+    return PopScope(
+      canPop: false,
+      onPopInvokedWithResult: (didPop, _) {
+        if (didPop) return;
+        unawaited(_handleSystemBack());
+      },
+      child: Scaffold(
         appBar: AppBar(
           title: const Text('Select Exercises'),
           leading: IconButton(
             icon: const Icon(Icons.close),
             onPressed: _showCancelDialog,
           ),
         ),
         body: ExerciseSelectorScreen(
           selectionMode: true,
           onExercisesSelected: _startWithSelectedExercises,
         ),
-    );
+      ),
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workout-logger/lib/screens/workout_flow_screen.dart` around lines 218 - 235,
The exercise selector path bypasses the PopScope protection causing system-back
to silently pop while hasActiveWorkout remains true; wrap the selector in the
same PopScope (or move the existing PopScope so it encloses both branches) and
reuse the same onPopInvokedWithResult behavior that calls _handleSystemBack when
didPop is false so hardware/gesture back triggers the same cancel flow as the
explicit close button; ensure you keep canPop: false and reference
provider.currentExerciseLogs, _buildExerciseSelector, PopScope, and
_handleSystemBack when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@workout-logger/lib/screens/workout_flow_screen.dart`:
- Around line 66-78: The current resolvers _resolvedProgramDay and
_resolvedProgramWeek prefer widget.programDay/Week over the provider’s active
values, which can render stale context when a live workout exists; change both
functions to prefer provider.activeProgramDay/activeProgramWeek whenever
provider.hasActiveWorkout (and that active value is non-null), falling back to
widget.programDay/Week only when there is no active session or the provider
value is null; ensure _slotForIndex (which calls _resolvedProgramDay and accepts
an optional provider) continues to pass through the resolved provider so lookups
use the new precedence.

In `@workout-logger/lib/services/workout_provider.dart`:
- Around line 188-275: In _restoreDraftIfAny validate the restored index against
the restoredLogs length: compute maxIndex = restoredLogs.isEmpty ? 0 :
restoredLogs.length - 1 and if restoredLogs.isNotEmpty and indexFromDraft is
outside 0..maxIndex then debugPrint a clear message and await _clearDraft() and
return (or alternatively log+clear), otherwise set _currentExerciseIndex =
indexFromDraft.clamp(0, maxIndex) (remove the redundant .toInt()); also remove
the redundant assignment _activeSession = null since it's already null. Ensure
you reference indexFromDraft, restoredLogs, maxIndex, _clearDraft,
_currentExerciseIndex, and _activeSession in the changes.

---

Outside diff comments:
In `@workout-logger/lib/screens/workout_flow_screen.dart`:
- Around line 218-235: The exercise selector path bypasses the PopScope
protection causing system-back to silently pop while hasActiveWorkout remains
true; wrap the selector in the same PopScope (or move the existing PopScope so
it encloses both branches) and reuse the same onPopInvokedWithResult behavior
that calls _handleSystemBack when didPop is false so hardware/gesture back
triggers the same cancel flow as the explicit close button; ensure you keep
canPop: false and reference provider.currentExerciseLogs,
_buildExerciseSelector, PopScope, and _handleSystemBack when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 49cc8ee9-f856-464a-9c9d-21c8f762b3c4

📥 Commits

Reviewing files that changed from the base of the PR and between f1256c6 and 8555ba0.

📒 Files selected for processing (6)
  • workout-logger/lib/screens/programs/program_detail_screen.dart
  • workout-logger/lib/screens/workout_flow_screen.dart
  • workout-logger/lib/services/workout_provider.dart
  • workout-logger/test/program_detail_screen_test.dart
  • workout-logger/test/test_utils/mock_storage_service.dart
  • workout-logger/test/workout_provider_test.dart

Comment on lines +66 to 78
ProgramDay? _resolvedProgramDay(WorkoutProvider provider) =>
widget.programDay ?? provider.activeProgramDay;

ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) =>
widget.programWeek ?? provider.activeProgramWeek;

ProgramExerciseSlot? _slotForIndex(int idx, {WorkoutProvider? provider}) {
final resolvedProvider = provider ?? context.read<WorkoutProvider>();
final day = _resolvedProgramDay(resolvedProvider);
if (day == null) return null;
final slots = day.exercises;
return idx < slots.length ? slots[idx] : null;
}

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.

🧹 Nitpick | 🔵 Trivial

Defensive precedence for _resolvedProgramDay / _resolvedProgramWeek.

When provider.hasActiveWorkout is true, the resolved program context drives slot lookup, deload targets, rest timers, and superset detection across the whole screen (_slotForIndex, _supersetGroupStart, _supersetNeedsMoreSets, _buildProgramMetaBanner, _completeSet). Today the resolver prefers widget.programDay/Week, which is fine for every caller in this PR, but it’s easy to regress: any future entry point that pushes WorkoutFlowScreen with a stale programDay while another program workout is active will render the wrong context against the live session.

Consider giving the active workout precedence whenever it exists, falling back to the widget’s args only when there’s no active session:

♻️ Proposed precedence flip
-  ProgramDay? _resolvedProgramDay(WorkoutProvider provider) =>
-      widget.programDay ?? provider.activeProgramDay;
-
-  ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) =>
-      widget.programWeek ?? provider.activeProgramWeek;
+  ProgramDay? _resolvedProgramDay(WorkoutProvider provider) =>
+      provider.hasActiveWorkout
+          ? (provider.activeProgramDay ?? widget.programDay)
+          : (widget.programDay ?? provider.activeProgramDay);
+
+  ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) =>
+      provider.hasActiveWorkout
+          ? (provider.activeProgramWeek ?? widget.programWeek)
+          : (widget.programWeek ?? provider.activeProgramWeek);
📝 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
ProgramDay? _resolvedProgramDay(WorkoutProvider provider) =>
widget.programDay ?? provider.activeProgramDay;
ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) =>
widget.programWeek ?? provider.activeProgramWeek;
ProgramExerciseSlot? _slotForIndex(int idx, {WorkoutProvider? provider}) {
final resolvedProvider = provider ?? context.read<WorkoutProvider>();
final day = _resolvedProgramDay(resolvedProvider);
if (day == null) return null;
final slots = day.exercises;
return idx < slots.length ? slots[idx] : null;
}
ProgramDay? _resolvedProgramDay(WorkoutProvider provider) =>
provider.hasActiveWorkout
? (provider.activeProgramDay ?? widget.programDay)
: (widget.programDay ?? provider.activeProgramDay);
ProgramWeek? _resolvedProgramWeek(WorkoutProvider provider) =>
provider.hasActiveWorkout
? (provider.activeProgramWeek ?? widget.programWeek)
: (widget.programWeek ?? provider.activeProgramWeek);
ProgramExerciseSlot? _slotForIndex(int idx, {WorkoutProvider? provider}) {
final resolvedProvider = provider ?? context.read<WorkoutProvider>();
final day = _resolvedProgramDay(resolvedProvider);
if (day == null) return null;
final slots = day.exercises;
return idx < slots.length ? slots[idx] : null;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workout-logger/lib/screens/workout_flow_screen.dart` around lines 66 - 78,
The current resolvers _resolvedProgramDay and _resolvedProgramWeek prefer
widget.programDay/Week over the provider’s active values, which can render stale
context when a live workout exists; change both functions to prefer
provider.activeProgramDay/activeProgramWeek whenever provider.hasActiveWorkout
(and that active value is non-null), falling back to widget.programDay/Week only
when there is no active session or the provider value is null; ensure
_slotForIndex (which calls _resolvedProgramDay and accepts an optional provider)
continues to pass through the resolved provider so lookups use the new
precedence.

Comment on lines +188 to +275
Future<void> _restoreDraftIfAny() async {
String? rawDraft;
try {
rawDraft = await _storage.getSetting(_draftKey);
} catch (e) {
debugPrint('Failed to read active workout draft: $e');
return;
}

if (rawDraft == null || rawDraft.isEmpty) {
return;
}

try {
final decoded = jsonDecode(rawDraft);
if (decoded is! Map) {
throw const FormatException('Draft payload must be a JSON object.');
}
final draft = Map<String, dynamic>.from(decoded);

final schemaVersion = draft['schemaVersion'];
if (schemaVersion != _draftSchemaVersion) {
debugPrint(
'Unsupported active workout draft schema: $schemaVersion. Clearing draft.',
);
await _clearDraft();
return;
}

final startTimeRaw = draft['startTime'] as String?;
final logsRaw = draft['currentExerciseLogs'];
if (startTimeRaw == null || logsRaw is! List) {
throw const FormatException('Draft is missing required fields.');
}

final restoredLogs = logsRaw
.map(
(log) =>
ExerciseLog.fromJson(Map<String, dynamic>.from(log as Map)),
)
.toList();

final programDayRaw = draft['programDay'];
ProgramDay? restoredProgramDay;
if (programDayRaw is Map) {
restoredProgramDay = ProgramDay.fromJson(
Map<String, dynamic>.from(programDayRaw),
);
}

final programWeekRaw = draft['programWeek'];
ProgramWeek? restoredProgramWeek;
if (programWeekRaw is Map) {
restoredProgramWeek = ProgramWeek.fromJson(
Map<String, dynamic>.from(programWeekRaw),
);
}

final routineId = draft['routineId'] as String?;
Routine? restoredRoutine;
if (routineId != null) {
try {
restoredRoutine = _routines.firstWhere((r) => r.id == routineId);
} catch (_) {
restoredRoutine = null;
}
}

final indexFromDraft =
(draft['currentExerciseIndex'] as num?)?.toInt() ?? 0;
final maxIndex = restoredLogs.isEmpty ? 0 : restoredLogs.length - 1;

_draftRestoreInProgress = true;
_activeSession = null;
_workoutStartTime = DateTime.parse(startTimeRaw);
_activeRoutine = restoredRoutine;
_activeProgramDay = restoredProgramDay;
_activeProgramWeek = restoredProgramWeek;
_currentExerciseLogs = restoredLogs;
_currentExerciseIndex = indexFromDraft.clamp(0, maxIndex).toInt();
notifyListeners();
} catch (e) {
debugPrint('Failed to restore active workout draft: $e');
await _clearDraft();
} finally {
_draftRestoreInProgress = false;
}
}

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.

🧹 Nitpick | 🔵 Trivial

Defensive: clear the draft on stale-state shapes too.

_restoreDraftIfAny treats only schema-version mismatch and parse exceptions as “clear and bail.” A subtle gap: if a future schema regression yields a payload where currentExerciseIndex references an index past restoredLogs.length (e.g. a partial truncation), the clamp at line 267 silently lands the user on an arbitrary slot rather than surfacing the inconsistency. Consider validating that indexFromDraft is within [0, restoredLogs.length - 1] (when logs are non-empty) and either log+clear when out-of-range, or accept the clamp and add a debug log so it doesn’t fail silently. Not blocking — current behavior is graceful degradation — but a log line will pay for itself in diagnostics.

Also nit: _activeSession = null on line 261 is redundant (already null at init) and .toInt() on line 267 is a no-op since int.clamp(int, int) already returns int.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workout-logger/lib/services/workout_provider.dart` around lines 188 - 275, In
_restoreDraftIfAny validate the restored index against the restoredLogs length:
compute maxIndex = restoredLogs.isEmpty ? 0 : restoredLogs.length - 1 and if
restoredLogs.isNotEmpty and indexFromDraft is outside 0..maxIndex then
debugPrint a clear message and await _clearDraft() and return (or alternatively
log+clear), otherwise set _currentExerciseIndex = indexFromDraft.clamp(0,
maxIndex) (remove the redundant .toInt()); also remove the redundant assignment
_activeSession = null since it's already null. Ensure you reference
indexFromDraft, restoredLogs, maxIndex, _clearDraft, _currentExerciseIndex, and
_activeSession in the changes.

@Devasy
Devasy merged commit 00062de into main Apr 26, 2026
2 checks passed
@Devasy
Devasy deleted the feat/active-workout-protection branch April 26, 2026 04:32
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.

1 participant