From 7fdcaa41b3f9639bd420b1da81505354cc9858d0 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:02:30 +0530 Subject: [PATCH 1/3] feat: Implement workout conflict handling and draft persistence - 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 --- workout-logger/lib/screens/home_screen.dart | 185 +++++++----- .../programs/program_detail_screen.dart | 54 ++-- .../lib/screens/routines_screen.dart | 52 ++-- .../widgets/workout_conflict_dialog.dart | 63 ++++ .../lib/screens/workout_flow_screen.dart | 117 ++++++-- .../lib/services/workout_provider.dart | 183 +++++++++++- .../test/performance_regression_test.dart | 1 - .../test/test_utils/mock_storage_service.dart | 6 +- .../test/workout_provider_test.dart | 273 ++++++++++++++---- 9 files changed, 747 insertions(+), 187 deletions(-) create mode 100644 workout-logger/lib/screens/widgets/workout_conflict_dialog.dart diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 56b060d..1d9d226 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -12,6 +12,7 @@ import 'routines_screen.dart'; import 'analytics_screen.dart'; import 'exercise_library_screen.dart'; import 'profile_screen.dart'; +import 'widgets/workout_conflict_dialog.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -74,7 +75,9 @@ class _HomeScreenState extends State { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: isSelected ? AppTheme.primaryColor.withOpacity(0.2) : Colors.transparent, + color: isSelected + ? AppTheme.primaryColor.withOpacity(0.2) + : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Column( @@ -82,14 +85,18 @@ class _HomeScreenState extends State { children: [ Icon( icon, - color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary, + color: isSelected + ? AppTheme.primaryColor + : AppTheme.textSecondary, size: 24, ), const SizedBox(height: 4), Text( label, style: TextStyle( - color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary, + color: isSelected + ? AppTheme.primaryColor + : AppTheme.textSecondary, fontSize: 12, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), @@ -135,8 +142,10 @@ class DashboardTab extends StatelessWidget { Widget _buildHeader(BuildContext context) { final now = DateTime.now(); - final greeting = now.hour < 12 ? 'Good morning' : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); - + final greeting = now.hour < 12 + ? 'Good morning' + : (now.hour < 17 ? 'Good afternoon' : 'Good evening'); + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -145,9 +154,9 @@ class DashboardTab extends StatelessWidget { children: [ Text( greeting, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppTheme.textSecondary, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 4), Text( @@ -159,7 +168,8 @@ class DashboardTab extends StatelessWidget { IconButton( onPressed: () { // Navigate to Profile tab (index 4) - final homeState = context.findAncestorStateOfType<_HomeScreenState>(); + final homeState = context + .findAncestorStateOfType<_HomeScreenState>(); if (homeState != null) { homeState.setState(() => homeState._currentIndex = 4); } @@ -173,7 +183,7 @@ class DashboardTab extends StatelessWidget { Widget _buildQuickStartCard(BuildContext context) { final provider = context.watch(); - + return Container( width: double.infinity, padding: const EdgeInsets.all(AppSpacing.lg), @@ -223,7 +233,7 @@ class DashboardTab extends StatelessWidget { ), ), Text( - provider.routines.isEmpty + provider.routines.isEmpty ? 'Quick start or create a routine' : '${provider.routines.length} routines available', style: TextStyle( @@ -273,20 +283,19 @@ class DashboardTab extends StatelessWidget { return FutureBuilder>( future: context.read().getQuickStats(), builder: (context, snapshot) { - final stats = snapshot.data ?? { - 'totalWorkouts': 0, - 'weeklyWorkouts': 0, - 'weeklyVolume': 0.0, - 'exercisesThisWeek': 0, - }; + final stats = + snapshot.data ?? + { + 'totalWorkouts': 0, + 'weeklyWorkouts': 0, + 'weeklyVolume': 0.0, + 'exercisesThisWeek': 0, + }; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'This Week', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('This Week', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: AppSpacing.md), Row( children: [ @@ -302,7 +311,9 @@ class DashboardTab extends StatelessWidget { Expanded( child: _StatCard( icon: Icons.trending_up, - value: _formatVolume(stats['weeklyVolume']?.toDouble() ?? 0), + value: _formatVolume( + stats['weeklyVolume']?.toDouble() ?? 0, + ), label: 'Volume (kg)', color: AppTheme.success, ), @@ -357,11 +368,7 @@ class DashboardTab extends StatelessWidget { ), child: Column( children: [ - Icon( - Icons.fitness_center, - size: 48, - color: AppTheme.textMuted, - ), + Icon(Icons.fitness_center, size: 48, color: AppTheme.textMuted), const SizedBox(height: AppSpacing.md), Text( 'No workouts yet', @@ -396,7 +403,9 @@ class DashboardTab extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.sm), - ...recentSessions.map((session) => _RecentWorkoutCard(session: session)), + ...recentSessions.map( + (session) => _RecentWorkoutCard(session: session), + ), ], ); } @@ -405,10 +414,7 @@ class DashboardTab extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Quick Actions', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('Quick Actions', style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: AppSpacing.md), Row( children: [ @@ -429,7 +435,9 @@ class DashboardTab extends StatelessWidget { label: 'Exercises', onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (_) => const ExerciseLibraryScreen()), + MaterialPageRoute( + builder: (_) => const ExerciseLibraryScreen(), + ), ), ), ), @@ -439,25 +447,51 @@ class DashboardTab extends StatelessWidget { ); } - void _startQuickWorkout(BuildContext context) { - Navigator.push( + Future _resolveWorkoutConflict( + BuildContext context, + WorkoutProvider provider, + ) async { + final action = await showWorkoutConflictDialog( context, - MaterialPageRoute( - builder: (_) => const WorkoutFlowScreen(isQuickStart: true), - ), + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), ); + return action ?? StartWorkoutConflictAction.cancel; + } + + Future _startQuickWorkout(BuildContext context) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = + StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + exerciseIds: const [], + onConflict: () async { + conflictAction = await _resolveWorkoutConflict(context, provider); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const WorkoutFlowScreen(isQuickStart: true), + ), + ); + } } void _showRoutineSelector(BuildContext context) { final provider = context.read(); - + showModalBottomSheet( context: context, backgroundColor: AppTheme.cardColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (context) => Container( + builder: (sheetContext) => Container( padding: const EdgeInsets.all(AppSpacing.lg), child: Column( mainAxisSize: MainAxisSize.min, @@ -468,31 +502,51 @@ class DashboardTab extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: AppSpacing.md), - ...provider.routines.map((routine) => ListTile( - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.fitness_center, - color: AppTheme.primaryColor, + ...provider.routines.map( + (routine) => ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.fitness_center, + color: AppTheme.primaryColor, + ), ), + title: Text(routine.name), + subtitle: Text('${routine.exerciseIds.length} exercises'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + Navigator.pop(sheetContext); + + StartWorkoutConflictAction conflictAction = + StartWorkoutConflictAction.cancel; + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + conflictAction = await _resolveWorkoutConflict( + context, + provider, + ); + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || + conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WorkoutFlowScreen(routine: routine), + ), + ); + } + }, ), - title: Text(routine.name), - subtitle: Text('${routine.exerciseIds.length} exercises'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); - }, - )), + ), const SizedBox(height: AppSpacing.md), ], ), @@ -621,10 +675,7 @@ class _RecentWorkoutCard extends StatelessWidget { children: [ Text( timeFormat.format(session.date), - style: const TextStyle( - fontSize: 12, - color: AppTheme.textMuted, - ), + style: const TextStyle(fontSize: 12, color: AppTheme.textMuted), ), const SizedBox(height: 2), Text( diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index 90ee035..8bb2c5f 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -11,6 +11,7 @@ import '../../models/models.dart'; import '../../services/workout_provider.dart'; import '../../theme/app_theme.dart'; import '../workout_flow_screen.dart'; +import '../widgets/workout_conflict_dialog.dart'; class ProgramDetailScreen extends StatefulWidget { final TrainingProgram program; @@ -84,6 +85,34 @@ class _ProgramDetailScreenState extends State { ); } + Future _startProgramDayWorkout(ProgramDay day, ProgramWeek week) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = + StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + exerciseIds: day.exercises.map((slot) => slot.exerciseId).toList(), + onConflict: () async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + conflictAction = action ?? StartWorkoutConflictAction.cancel; + return conflictAction; + }, + ); + + if (!mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WorkoutFlowScreen(programDay: day, programWeek: week), + ), + ); + } + } + // ── Header ────────────────────────────────────────────────────────────── Widget _buildHeader() { @@ -234,8 +263,7 @@ class _ProgramDetailScreenState extends State { final phase = entry.value; final fraction = (phase.endWeek - phase.startWeek + 1) / _program.totalWeeks; - final color = - _phaseColors[entry.key % _phaseColors.length]; + final color = _phaseColors[entry.key % _phaseColors.length]; return Expanded( flex: ((fraction * 100).round()).clamp(1, 100), child: Container( @@ -412,9 +440,7 @@ class _ProgramDetailScreenState extends State { indent: AppSpacing.md, endIndent: AppSpacing.md, ), - ...week.days.map( - (day) => _buildDaySection(day, week), - ), + ...week.days.map((day) => _buildDaySection(day, week)), if (week.notes != null) Padding( padding: const EdgeInsets.fromLTRB( @@ -551,15 +577,7 @@ class _ProgramDetailScreenState extends State { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen( - programDay: day, - programWeek: week, - ), - ), - ), + onPressed: () => _startProgramDayWorkout(day, week), icon: const Icon(Icons.play_arrow, size: 18), label: Text('Start ${day.name}'), style: ElevatedButton.styleFrom( @@ -627,8 +645,9 @@ class _ProgramDetailScreenState extends State { final name = exercise?.name ?? slot.exerciseId; // Apply deload adjustments for display - final displaySets = - week.isDeload ? (slot.sets - week.deloadSetReduction).clamp(1, 99) : slot.sets; + final displaySets = week.isDeload + ? (slot.sets - week.deloadSetReduction).clamp(1, 99) + : slot.sets; final displayIntensity = week.isDeload ? week.deloadIntensityFactor : 1.0; final repRange = slot.minReps == slot.maxReps @@ -682,8 +701,7 @@ class _ProgramDetailScreenState extends State { Icons.timer_outlined, '${slot.restSeconds}s rest', ), - if (slot.tempo != null) - _infoChip(Icons.speed, slot.tempo!), + if (slot.tempo != null) _infoChip(Icons.speed, slot.tempo!), if (slot.weightPercentage != null) _infoChip( Icons.fitness_center, diff --git a/workout-logger/lib/screens/routines_screen.dart b/workout-logger/lib/screens/routines_screen.dart index 04288e0..81380f4 100644 --- a/workout-logger/lib/screens/routines_screen.dart +++ b/workout-logger/lib/screens/routines_screen.dart @@ -9,6 +9,35 @@ import '../theme/app_theme.dart'; import '../data/exercise_database.dart'; import 'workout_flow_screen.dart'; import 'programs/programs_screen.dart'; +import 'widgets/workout_conflict_dialog.dart'; + +Future _startRoutineWorkoutFlow( + BuildContext context, + Routine routine, +) async { + final provider = context.read(); + StartWorkoutConflictAction conflictAction = StartWorkoutConflictAction.cancel; + + final started = await provider.startWorkoutSafely( + routine: routine, + onConflict: () async { + final action = await showWorkoutConflictDialog( + context, + workoutStartTime: provider.workoutStartTime ?? DateTime.now(), + ); + conflictAction = action ?? StartWorkoutConflictAction.cancel; + return conflictAction; + }, + ); + + if (!context.mounted) return; + if (started || conflictAction == StartWorkoutConflictAction.resume) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => WorkoutFlowScreen(routine: routine)), + ); + } +} class RoutinesScreen extends StatelessWidget { const RoutinesScreen({super.key}); @@ -27,12 +56,7 @@ class RoutinesScreen extends StatelessWidget { ], ), ), - body: const TabBarView( - children: [ - _RoutinesTab(), - ProgramsScreen(), - ], - ), + body: const TabBarView(children: [_RoutinesTab(), ProgramsScreen()]), ), ); } @@ -168,14 +192,7 @@ class _RoutineCard extends StatelessWidget { icon: const Icon(Icons.play_circle_fill), color: AppTheme.primaryColor, iconSize: 40, - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); - }, + onPressed: () => _startRoutineWorkoutFlow(context, routine), ), ], ), @@ -737,12 +754,7 @@ class RoutineDetailScreen extends StatelessWidget { ), floatingActionButton: FloatingActionButton.extended( onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(routine: routine), - ), - ); + _startRoutineWorkoutFlow(context, routine); }, icon: const Icon(Icons.play_arrow), label: const Text('Start Workout'), diff --git a/workout-logger/lib/screens/widgets/workout_conflict_dialog.dart b/workout-logger/lib/screens/widgets/workout_conflict_dialog.dart new file mode 100644 index 0000000..c21bf22 --- /dev/null +++ b/workout-logger/lib/screens/widgets/workout_conflict_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../services/workout_provider.dart'; +import '../../theme/app_theme.dart'; + +class WorkoutConflictDialog extends StatelessWidget { + final DateTime workoutStartTime; + + const WorkoutConflictDialog({super.key, required this.workoutStartTime}); + + String _formatStartTime() { + final now = DateTime.now(); + final isToday = + now.year == workoutStartTime.year && + now.month == workoutStartTime.month && + now.day == workoutStartTime.day; + + return isToday + ? DateFormat('HH:mm').format(workoutStartTime) + : DateFormat('MMM d HH:mm').format(workoutStartTime); + } + + @override + Widget build(BuildContext context) { + final formattedStart = _formatStartTime(); + + return AlertDialog( + title: const Text('Workout already in progress'), + content: Text('You have an unfinished workout from $formattedStart.'), + actions: [ + TextButton( + onPressed: () => + Navigator.of(context).pop(StartWorkoutConflictAction.resume), + child: const Text('Resume'), + ), + TextButton( + onPressed: () => Navigator.of( + context, + ).pop(StartWorkoutConflictAction.discardAndStart), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Discard & start new'), + ), + TextButton( + onPressed: () => + Navigator.of(context).pop(StartWorkoutConflictAction.cancel), + child: const Text('Cancel'), + ), + ], + ); + } +} + +Future showWorkoutConflictDialog( + BuildContext context, { + required DateTime workoutStartTime, +}) { + return showDialog( + context: context, + builder: (context) => + WorkoutConflictDialog(workoutStartTime: workoutStartTime), + ); +} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 50cc180..07fcd17 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -29,6 +29,8 @@ class WorkoutFlowScreen extends StatefulWidget { State createState() => _WorkoutFlowScreenState(); } +enum _LeaveAction { discard, keep, cancel } + class _WorkoutFlowScreenState extends State { // Rest timer state bool _isResting = false; @@ -109,7 +111,10 @@ class _WorkoutFlowScreenState extends State { if (slot == null) return false; if (index >= provider.currentExerciseLogs.length) return false; final targetSets = (widget.programWeek?.isDeload == true) - ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp(1, 99) + ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp( + 1, + 99, + ) : slot.sets; final logged = provider.currentExerciseLogs[index].sets.length; return logged < targetSets; @@ -118,9 +123,19 @@ class _WorkoutFlowScreenState extends State { void _initializeWorkout() { final provider = context.read(); + if (provider.hasActiveWorkout) { + final slot = _slotForIndex(provider.currentExerciseIndex); + if (slot != null) { + _restSeconds = slot.restSeconds; + } + _loadLastSessionData(); + return; + } + if (widget.programDay != null) { - final exerciseIds = - widget.programDay!.exercises.map((s) => s.exerciseId).toList(); + final exerciseIds = widget.programDay!.exercises + .map((s) => s.exerciseId) + .toList(); provider.startWorkout(exerciseIds: exerciseIds); // Set initial rest time from first slot final firstSlot = _slotForIndex(0); @@ -149,7 +164,8 @@ class _WorkoutFlowScreenState extends State { _currentReps = lastSet.reps; // Sync controllers using display unit final displayWeight = settings.toDisplay(_currentWeight); - _mainWeightController.text = displayWeight == displayWeight.truncateToDouble() + _mainWeightController.text = + displayWeight == displayWeight.truncateToDouble() ? displayWeight.toStringAsFixed(0) : displayWeight.toStringAsFixed(1); _mainRepsController.text = _currentReps.toString(); @@ -185,10 +201,17 @@ class _WorkoutFlowScreenState extends State { return _buildExerciseSelector(); } - return Scaffold( - backgroundColor: AppTheme.backgroundColor, - body: SafeArea( - child: _isResting ? _buildRestTimerView() : _buildWorkoutView(), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + unawaited(_handleSystemBack()); + }, + child: Scaffold( + backgroundColor: AppTheme.backgroundColor, + body: SafeArea( + child: _isResting ? _buildRestTimerView() : _buildWorkoutView(), + ), ), ); } @@ -209,12 +232,12 @@ class _WorkoutFlowScreenState extends State { ); } - void _startWithSelectedExercises(List exerciseIds) { + Future _startWithSelectedExercises(List exerciseIds) async { if (exerciseIds.isEmpty) return; final provider = context.read(); // Restart workout with selected exercises - provider.cancelWorkout(); + await provider.cancelWorkout(); provider.startWorkout(exerciseIds: exerciseIds); } @@ -1120,7 +1143,11 @@ class _WorkoutFlowScreenState extends State { color: AppTheme.textSecondary, ), if (slot.tempo != null) - _programChip(icon: Icons.speed, label: 'Tempo ${slot.tempo}', color: AppTheme.secondaryColor), + _programChip( + icon: Icons.speed, + label: 'Tempo ${slot.tempo}', + color: AppTheme.secondaryColor, + ), if (slot.weightPercentage != null) _programChip( icon: Icons.fitness_center, @@ -1130,7 +1157,11 @@ class _WorkoutFlowScreenState extends State { color: AppTheme.primaryColor, ), if (slot.supersetGroupId != null) - _programChip(icon: Icons.link, label: 'Superset', color: AppTheme.secondaryColor), + _programChip( + icon: Icons.link, + label: 'Superset', + color: AppTheme.secondaryColor, + ), ], ), if (slot.notes != null) ...[ @@ -1300,7 +1331,8 @@ class _WorkoutFlowScreenState extends State { // Superset auto-advance: if next exercise is in the same superset group // AND the next slot still needs more sets, advance immediately. - final isSupersetPair = currentSlot?.supersetGroupId != null && + final isSupersetPair = + currentSlot?.supersetGroupId != null && nextSlot?.supersetGroupId == currentSlot?.supersetGroupId; if (isSupersetPair && @@ -1459,22 +1491,69 @@ class _WorkoutFlowScreenState extends State { ); } + Future _handleSystemBack() async { + final action = await _showLeaveDialog(); + if (!mounted) return; + + if (action == _LeaveAction.discard) { + await context.read().cancelWorkout(); + if (mounted) { + Navigator.of(context).pop(); + } + return; + } + + if (action == _LeaveAction.keep) { + Navigator.of(context).pop(); + } + } + + Future<_LeaveAction> _showLeaveDialog() async { + final action = await showDialog<_LeaveAction>( + context: context, + builder: (context) => AlertDialog( + title: const Text('Leave workout?'), + content: const Text( + 'Your progress is saved. You can resume it next time you start a workout.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(_LeaveAction.discard), + style: TextButton.styleFrom(foregroundColor: AppTheme.error), + child: const Text('Discard workout'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_LeaveAction.keep), + child: const Text('Keep & exit'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(_LeaveAction.cancel), + child: const Text('Cancel'), + ), + ], + ), + ); + + return action ?? _LeaveAction.cancel; + } + void _showCancelDialog() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Cancel Workout?'), content: const Text('Your progress will not be saved.'), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(dialogContext), child: const Text('Continue Workout'), ), TextButton( - onPressed: () { - context.read().cancelWorkout(); - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Close workout screen + onPressed: () async { + await context.read().cancelWorkout(); + if (!mounted) return; + Navigator.pop(dialogContext); // Close dialog + Navigator.pop(this.context); // Close workout screen }, child: const Text( 'Cancel Workout', diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index 98b660c..a583ece 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -13,6 +13,9 @@ // Following Dependency Inversion Principle: this class now depends on // abstractions (IStorageService, IMLService) rather than concrete implementations. +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import '../models/models.dart'; @@ -24,6 +27,12 @@ import 'strategies/target_calculator.dart'; import 'managers/program_manager.dart'; import 'utils/exercise_history.dart'; +enum StartWorkoutConflictAction { resume, discardAndStart, cancel } + +class WorkoutInProgressError extends StateError { + WorkoutInProgressError() : super('A workout is already in progress.'); +} + class WorkoutProvider extends ChangeNotifier { final IStorageService _storage; final IMLService _mlService; @@ -46,6 +55,9 @@ class WorkoutProvider extends ChangeNotifier { int _currentExerciseIndex = 0; List _currentExerciseLogs = []; DateTime? _workoutStartTime; + static const String _draftKey = 'active_workout_draft'; + static const int _draftSchemaVersion = 1; + bool _draftRestoreInProgress = false; // Getters List get sessions => _sessions; @@ -78,6 +90,7 @@ class WorkoutProvider extends ChangeNotifier { await _storage.init(); await loadAllData(); await _trainAllGrowthModels(); + await _restoreDraftIfAny(); } Future loadAllData() async { @@ -119,6 +132,112 @@ class WorkoutProvider extends ChangeNotifier { } } + Future _persistDraft() async { + if (_draftRestoreInProgress) { + return; + } + + if (!hasActiveWorkout || _workoutStartTime == null) { + await _clearDraft(); + return; + } + + final draft = jsonEncode({ + 'schemaVersion': _draftSchemaVersion, + 'startTime': _workoutStartTime!.toIso8601String(), + 'routineId': _activeRoutine?.id, + 'currentExerciseIndex': _currentExerciseIndex, + 'currentExerciseLogs': _currentExerciseLogs + .map((log) => log.toJson()) + .toList(), + }); + + try { + await _storage.saveSetting(_draftKey, draft); + } catch (e) { + debugPrint('Failed to persist active workout draft: $e'); + } + } + + Future _clearDraft() async { + try { + await _storage.saveSetting(_draftKey, ''); + } catch (e) { + debugPrint('Failed to clear active workout draft: $e'); + } + } + + Future _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.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.from(log as Map)), + ) + .toList(); + + 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; + _currentExerciseLogs = restoredLogs; + _currentExerciseIndex = indexFromDraft.clamp(0, maxIndex).toInt(); + notifyListeners(); + } catch (e) { + debugPrint('Failed to restore active workout draft: $e'); + await _clearDraft(); + } finally { + _draftRestoreInProgress = false; + } + } + // ==================== EXERCISE HELPERS ==================== Exercise? getExercise(String id) { @@ -219,20 +338,32 @@ class WorkoutProvider extends ChangeNotifier { } for (final s in _sessions) { - for (final e in s.exercises) addReference(e.exerciseId, '_sessions'); + for (final e in s.exercises) { + addReference(e.exerciseId, '_sessions'); + } } for (final r in _routines) { - for (final id in r.exerciseIds) addReference(id, '_routines'); + for (final id in r.exerciseIds) { + addReference(id, '_routines'); + } + } + for (final t in _targets) { + addReference(t.exerciseId, '_targets'); + } + for (final l in _currentExerciseLogs) { + addReference(l.exerciseId, '_currentExerciseLogs'); } - for (final t in _targets) addReference(t.exerciseId, '_targets'); - for (final l in _currentExerciseLogs) addReference(l.exerciseId, '_currentExerciseLogs'); if (_activeRoutine != null) { - for (final id in _activeRoutine!.exerciseIds) addReference(id, '_activeRoutine'); + for (final id in _activeRoutine!.exerciseIds) { + addReference(id, '_activeRoutine'); + } } if (referencedIds.contains(exerciseId)) { final reasons = referenceReasons[exerciseId]?.join(', ') ?? 'unknown'; - debugPrint('Cannot delete custom exercise $exerciseId: still referenced in $reasons'); + debugPrint( + 'Cannot delete custom exercise $exerciseId: still referenced in $reasons', + ); return false; } @@ -254,6 +385,10 @@ class WorkoutProvider extends ChangeNotifier { /// Start a new workout with a routine void startWorkout({Routine? routine, List? exerciseIds}) { + if (hasActiveWorkout) { + throw WorkoutInProgressError(); + } + _workoutStartTime = DateTime.now(); _activeRoutine = routine; _currentExerciseIndex = 0; @@ -266,6 +401,27 @@ class WorkoutProvider extends ChangeNotifier { } notifyListeners(); + unawaited(_persistDraft()); + } + + Future startWorkoutSafely({ + Routine? routine, + List? exerciseIds, + required Future Function() onConflict, + }) async { + if (!hasActiveWorkout) { + startWorkout(routine: routine, exerciseIds: exerciseIds); + return true; + } + + final action = await onConflict(); + if (action == StartWorkoutConflictAction.discardAndStart) { + await cancelWorkout(); + startWorkout(routine: routine, exerciseIds: exerciseIds); + return true; + } + + return false; } /// Get current exercise being performed @@ -297,6 +453,7 @@ class WorkoutProvider extends ChangeNotifier { notes: currentLog.notes, ); notifyListeners(); + unawaited(_persistDraft()); } } @@ -312,6 +469,7 @@ class WorkoutProvider extends ChangeNotifier { notes: currentLog.notes, ); notifyListeners(); + unawaited(_persistDraft()); } } } @@ -321,6 +479,7 @@ class WorkoutProvider extends ChangeNotifier { if (_currentExerciseIndex < _currentExerciseLogs.length - 1) { _currentExerciseIndex++; notifyListeners(); + unawaited(_persistDraft()); return true; } return false; // No more exercises @@ -331,6 +490,7 @@ class WorkoutProvider extends ChangeNotifier { if (_currentExerciseIndex > 0) { _currentExerciseIndex--; notifyListeners(); + unawaited(_persistDraft()); return true; } return false; @@ -338,9 +498,12 @@ class WorkoutProvider extends ChangeNotifier { /// Jump directly to an exercise by index void goToExercise(int index) { - if (index >= 0 && index < _currentExerciseLogs.length) { + if (index >= 0 && + index < _currentExerciseLogs.length && + index != _currentExerciseIndex) { _currentExerciseIndex = index; notifyListeners(); + unawaited(_persistDraft()); } } @@ -365,6 +528,7 @@ class WorkoutProvider extends ChangeNotifier { ); await _storage.saveWorkoutSession(session); + await _clearDraft(); _sessions.insert(0, session); // Update growth models for performed exercises @@ -387,13 +551,14 @@ class WorkoutProvider extends ChangeNotifier { } /// Cancel workout without saving - void cancelWorkout() { + Future cancelWorkout() async { _activeSession = null; _activeRoutine = null; _currentExerciseIndex = 0; _currentExerciseLogs = []; _workoutStartTime = null; notifyListeners(); + await _clearDraft(); } // ==================== RECOMMENDATIONS ==================== @@ -699,7 +864,7 @@ class WorkoutProvider extends ChangeNotifier { // We use continue rather than break in case of external imports // that might temporarily violate the newest-first invariant. for (final session in _sessions) { - if (session.date.isBefore(weekAgo)) continue; + if (session.date.isBefore(weekAgo)) continue; for (final log in session.exercises) { final exercise = exerciseMap[log.exerciseId]; diff --git a/workout-logger/test/performance_regression_test.dart b/workout-logger/test/performance_regression_test.dart index 857f600..1f7cb66 100644 --- a/workout-logger/test/performance_regression_test.dart +++ b/workout-logger/test/performance_regression_test.dart @@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/managers/analytics_manager.dart'; import 'package:repforge/services/managers/exercise_manager.dart'; -import 'package:repforge/services/interfaces/ml_service_interface.dart'; import 'package:repforge/services/strategies/target_calculator.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/managers/program_manager.dart'; diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index 8f730e5..d7d24af 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -23,12 +23,14 @@ class MockStorageService implements IStorageService { bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; + int saveSettingCallCount = 0; // Public getters for test assertions List get customExercises => _customExercises; List get sessions => _sessions; List get routines => _routines; List get targets => _targets; + Map get settings => _settings; // Test helpers void addMockCustomExercise(Exercise exercise) { @@ -119,8 +121,7 @@ class MockStorageService implements IStorageService { final hi = start.isAfter(end) ? start : end; return _sessions .where( - (session) => - !session.date.isBefore(lo) && !session.date.isAfter(hi), + (session) => !session.date.isBefore(lo) && !session.date.isAfter(hi), ) .toList(); } @@ -222,6 +223,7 @@ class MockStorageService implements IStorageService { @override Future saveSetting(String key, String value) async { + saveSettingCallCount++; _settings[key] = value; } diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index 473f349..6306918 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -1,5 +1,7 @@ // Unit Tests for WorkoutProvider - Custom Exercise functionality +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/services/workout_provider.dart'; @@ -7,16 +9,24 @@ import 'package:repforge/services/managers/program_manager.dart'; import 'test_utils/mock_storage_service.dart'; void main() { - group('WorkoutProvider - Custom Exercise Tests', () { + group('WorkoutProvider Tests', () { late MockStorageService mockStorage; late WorkoutProvider provider; + const draftKey = 'active_workout_draft'; setUp(() async { mockStorage = MockStorageService(); - provider = WorkoutProvider(mockStorage, programManager: ProgramManager(mockStorage)); + provider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); await provider.init(); }); + Future flushAsync() async { + await Future.delayed(Duration.zero); + } + group('addCustomExercise', () { test('should add exercise to the list', () async { // Arrange @@ -176,8 +186,11 @@ void main() { }); group('getRecommendations / getLastSessionForExercise', () { - WorkoutSession session(String id, DateTime date, List logs) => - WorkoutSession(id: id, date: date, exercises: logs, duration: 30); + WorkoutSession session( + String id, + DateTime date, + List logs, + ) => WorkoutSession(id: id, date: date, exercises: logs, duration: 30); ExerciseLog log(String exerciseId, {List? sets}) => ExerciseLog( @@ -185,63 +198,55 @@ void main() { sets: sets ?? [WorkoutSet(weight: 50, reps: 8)], ); - test( - 'getLastSessionForExercise returns the most-recently-dated log ' - 'regardless of session insert order', - () async { - final markerSets = [WorkoutSet(weight: 100, reps: 5)]; - // Seed storage with sessions in non-chronological order so the - // provider's internal _sessions list does not happen to be sorted. - mockStorage.addMockSession( - session('old', DateTime(2025, 1, 1), [log('bench')]), - ); - mockStorage.addMockSession( - session('newest', DateTime(2025, 6, 1), [ - log('bench', sets: markerSets), - ]), - ); - mockStorage.addMockSession( - session('mid', DateTime(2025, 3, 1), [log('bench')]), - ); + test('getLastSessionForExercise returns the most-recently-dated log ' + 'regardless of session insert order', () async { + final markerSets = [WorkoutSet(weight: 100, reps: 5)]; + // Seed storage with sessions in non-chronological order so the + // provider's internal _sessions list does not happen to be sorted. + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [log('bench')]), + ); + mockStorage.addMockSession( + session('newest', DateTime(2025, 6, 1), [ + log('bench', sets: markerSets), + ]), + ); + mockStorage.addMockSession( + session('mid', DateTime(2025, 3, 1), [log('bench')]), + ); - // Re-init so the provider reloads sessions from the mock. - await provider.init(); + // Re-init so the provider reloads sessions from the mock. + await provider.init(); - final last = provider.getLastSessionForExercise('bench'); + final last = provider.getLastSessionForExercise('bench'); - expect(last, isNotNull); - expect(last!.sets.first.weight, 100); - expect(last.sets.first.reps, 5); - }, - ); + expect(last, isNotNull); + expect(last!.sets.first.weight, 100); + expect(last.sets.first.reps, 5); + }); test('getLastSessionForExercise returns null when never logged', () { expect(provider.getLastSessionForExercise('never_done'), isNull); }); - test( - 'getRecommendations bases output on the most-recent log', - () async { - final marker = [WorkoutSet(weight: 120, reps: 6)]; - mockStorage.addMockSession( - session('old', DateTime(2025, 1, 1), [log('bench')]), - ); - mockStorage.addMockSession( - session('newest', DateTime(2025, 8, 1), [ - log('bench', sets: marker), - ]), - ); + test('getRecommendations bases output on the most-recent log', () async { + final marker = [WorkoutSet(weight: 120, reps: 6)]; + mockStorage.addMockSession( + session('old', DateTime(2025, 1, 1), [log('bench')]), + ); + mockStorage.addMockSession( + session('newest', DateTime(2025, 8, 1), [log('bench', sets: marker)]), + ); - await provider.init(); + await provider.init(); - final recs = provider.getRecommendations('bench'); - // Default fallback is 3 generic sets at low weight; a real - // recommendation derived from the marker should be non-empty and - // weighted near 120kg, not the default. - expect(recs, isNotEmpty); - expect(recs.first.weight, greaterThanOrEqualTo(120)); - }, - ); + final recs = provider.getRecommendations('bench'); + // Default fallback is 3 generic sets at low weight; a real + // recommendation derived from the marker should be non-empty and + // weighted near 120kg, not the default. + expect(recs, isNotEmpty); + expect(recs.first.weight, greaterThanOrEqualTo(120)); + }); }); group('deleteCustomExercise', () { @@ -315,5 +320,171 @@ void main() { ); }); }); + + group('active workout draft persistence', () { + test('persists draft when adding a set', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 100, reps: 5)); + + await flushAsync(); + + final rawDraft = mockStorage.settings[draftKey]; + expect(rawDraft, isNotNull); + expect(rawDraft, isNotEmpty); + + final draft = Map.from(jsonDecode(rawDraft!) as Map); + final logs = List>.from( + (draft['currentExerciseLogs'] as List).map( + (log) => Map.from(log as Map), + ), + ); + final sets = List>.from( + (logs.first['sets'] as List).map( + (set) => Map.from(set as Map), + ), + ); + + expect(draft['schemaVersion'], equals(1)); + expect(sets.length, equals(1)); + }); + + test('persists currentExerciseIndex when navigating', () async { + provider.startWorkout(exerciseIds: const ['bench_press', 'squat']); + + final moved = provider.nextExercise(); + await flushAsync(); + + expect(moved, isTrue); + + final rawDraft = mockStorage.settings[draftKey]; + expect(rawDraft, isNotNull); + final draft = Map.from(jsonDecode(rawDraft!) as Map); + + expect(draft['currentExerciseIndex'], equals(1)); + }); + + test('clears draft after finishing workout', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 80, reps: 8)); + await flushAsync(); + + await provider.finishWorkout(); + + expect(mockStorage.settings[draftKey], equals('')); + }); + + test('clears draft after canceling workout', () async { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 80, reps: 8)); + await flushAsync(); + + await provider.cancelWorkout(); + + expect(mockStorage.settings[draftKey], equals('')); + }); + + test('restores draft during init without extra writes', () async { + final routine = Routine( + id: 'routine_1', + name: 'Push Day', + exerciseIds: const ['bench_press'], + ); + await mockStorage.saveRoutine(routine); + + final draft = jsonEncode({ + 'schemaVersion': 1, + 'startTime': DateTime(2026, 4, 26, 18, 43, 11).toIso8601String(), + 'routineId': routine.id, + 'currentExerciseIndex': 0, + 'currentExerciseLogs': [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 75, reps: 10)], + ).toJson(), + ], + }); + await mockStorage.saveSetting(draftKey, draft); + + final restoringProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await restoringProvider.init(); + + expect(restoringProvider.hasActiveWorkout, isTrue); + expect(restoringProvider.activeRoutine?.id, equals(routine.id)); + expect(restoringProvider.currentExerciseLogs.length, equals(1)); + expect( + restoringProvider.currentExerciseLogs.first.sets.length, + equals(1), + ); + expect(mockStorage.saveSettingCallCount, equals(1)); + }); + + test('restores draft even when routine was deleted', () async { + final draft = jsonEncode({ + 'schemaVersion': 1, + 'startTime': DateTime(2026, 4, 26, 18, 43, 11).toIso8601String(), + 'routineId': 'missing_routine', + 'currentExerciseIndex': 0, + 'currentExerciseLogs': [ + ExerciseLog( + exerciseId: 'bench_press', + sets: [WorkoutSet(weight: 90, reps: 6)], + ).toJson(), + ], + }); + await mockStorage.saveSetting(draftKey, draft); + + final restoringProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await restoringProvider.init(); + + expect(restoringProvider.hasActiveWorkout, isTrue); + expect(restoringProvider.activeRoutine, isNull); + expect(restoringProvider.currentExerciseLogs.length, equals(1)); + }); + + test('malformed draft is cleared and does not crash init', () async { + await mockStorage.saveSetting(draftKey, '{not valid json'); + + final restoringProvider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + await restoringProvider.init(); + + expect(restoringProvider.hasActiveWorkout, isFalse); + expect(mockStorage.settings[draftKey], equals('')); + }); + + test('throws when startWorkout is called with active workout', () { + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 100, reps: 5)); + + expect( + () => provider.startWorkout(exerciseIds: const ['squat']), + throwsA(isA()), + ); + expect( + provider.currentExerciseLogs.first.exerciseId, + equals('bench_press'), + ); + expect(provider.currentExerciseLogs.first.sets.length, equals(1)); + }); + + test('does not throw on first startWorkout call', () { + expect(provider.hasActiveWorkout, isFalse); + + expect( + () => provider.startWorkout(exerciseIds: const ['bench_press']), + returnsNormally, + ); + + expect(provider.hasActiveWorkout, isTrue); + }); + }); }); } From f1256c60e1f040c747a0dfbeb8f8e8572894762b Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:07:52 +0530 Subject: [PATCH 2/3] Refactor code structure for improved readability and maintainability Co-authored-by: Copilot --- workout-logger/lib/screens/home_screen.dart | 6 +++++- workout-logger/lib/screens/workout_flow_screen.dart | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 1d9d226..ba453a7 100644 --- a/workout-logger/lib/screens/home_screen.dart +++ b/workout-logger/lib/screens/home_screen.dart @@ -396,7 +396,11 @@ class DashboardTab extends StatelessWidget { ), TextButton( onPressed: () { - // Navigate to history tab + final homeState = context + .findAncestorStateOfType<_HomeScreenState>(); + if (homeState != null) { + homeState.setState(() => homeState._currentIndex = 1); + } }, child: const Text('See All'), ), diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 07fcd17..6da8654 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -383,6 +383,11 @@ class _WorkoutFlowScreenState extends State { if (currentSetIndex >= recommendations.length) return const SizedBox(); final rec = recommendations[currentSetIndex]; + final settings = context.watch(); + final displayWeight = settings.toDisplay(rec.weight); + final displayWeightText = displayWeight == displayWeight.truncateToDouble() + ? displayWeight.toStringAsFixed(0) + : displayWeight.toStringAsFixed(1); final confidenceColor = rec.confidence == 'high' ? AppTheme.success : (rec.confidence == 'medium' ? AppTheme.warning : AppTheme.textMuted); @@ -424,7 +429,7 @@ class _WorkoutFlowScreenState extends State { style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), ), Text( - '${rec.weight}kg × ${rec.reps} reps', + '$displayWeightText ${settings.unitLabel} × ${rec.reps} reps', style: const TextStyle( color: AppTheme.textPrimary, fontSize: 18, From 8555ba0e2aac0231b532ae8040f50a2d417d8367 Mon Sep 17 00:00:00 2001 From: Devasy Patel <110348311+Devasy23@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:03:13 +0530 Subject: [PATCH 3/3] feat: Enhance workout management with program context persistence and conflict resolution --- .../programs/program_detail_screen.dart | 14 ++- .../lib/screens/workout_flow_screen.dart | 89 ++++++++----- .../lib/services/workout_provider.dart | 85 +++++++++++-- .../test/program_detail_screen_test.dart | 117 ++++++++++++++++++ .../test/test_utils/mock_storage_service.dart | 7 ++ .../test/workout_provider_test.dart | 86 +++++++++++++ 6 files changed, 354 insertions(+), 44 deletions(-) create mode 100644 workout-logger/test/program_detail_screen_test.dart diff --git a/workout-logger/lib/screens/programs/program_detail_screen.dart b/workout-logger/lib/screens/programs/program_detail_screen.dart index 8bb2c5f..aeb71d6 100644 --- a/workout-logger/lib/screens/programs/program_detail_screen.dart +++ b/workout-logger/lib/screens/programs/program_detail_screen.dart @@ -92,6 +92,8 @@ class _ProgramDetailScreenState extends State { final started = await provider.startWorkoutSafely( exerciseIds: day.exercises.map((slot) => slot.exerciseId).toList(), + programDay: day, + programWeek: week, onConflict: () async { final action = await showWorkoutConflictDialog( context, @@ -104,10 +106,20 @@ class _ProgramDetailScreenState extends State { if (!mounted) return; if (started || conflictAction == StartWorkoutConflictAction.resume) { + final resumeProgramDay = provider.hasActiveWorkout + ? provider.activeProgramDay + : day; + final resumeProgramWeek = provider.hasActiveWorkout + ? provider.activeProgramWeek + : week; + Navigator.push( context, MaterialPageRoute( - builder: (_) => WorkoutFlowScreen(programDay: day, programWeek: week), + builder: (_) => WorkoutFlowScreen( + programDay: resumeProgramDay, + programWeek: resumeProgramWeek, + ), ), ); } diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 6da8654..2890b3d 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -63,17 +63,31 @@ class _WorkoutFlowScreenState extends State { }); } - ProgramExerciseSlot? _slotForIndex(int idx) { - if (widget.programDay == null) return null; - final slots = widget.programDay!.exercises; + 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(); + final day = _resolvedProgramDay(resolvedProvider); + if (day == null) return null; + final slots = day.exercises; return idx < slots.length ? slots[idx] : null; } /// Finds the index of the first exercise in the same superset group, scanning /// backward from [fromIdx]. - int _supersetGroupStart(int fromIdx, String groupId) { + int _supersetGroupStart( + int fromIdx, + String groupId, { + WorkoutProvider? provider, + }) { int start = fromIdx; - while (start > 0 && _slotForIndex(start - 1)?.supersetGroupId == groupId) { + while (start > 0 && + _slotForIndex(start - 1, provider: provider)?.supersetGroupId == + groupId) { start--; } return start; @@ -86,15 +100,13 @@ class _WorkoutFlowScreenState extends State { required int endIdx, required WorkoutProvider provider, }) { + final week = _resolvedProgramWeek(provider); for (int i = startIdx; i <= endIdx; i++) { - final slot = _slotForIndex(i); + final slot = _slotForIndex(i, provider: provider); if (slot == null) continue; if (i >= provider.currentExerciseLogs.length) continue; - final targetSets = (widget.programWeek?.isDeload == true) - ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp( - 1, - 99, - ) + final targetSets = (week?.isDeload == true) + ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) : slot.sets; final logged = provider.currentExerciseLogs[i].sets.length; if (logged < targetSets) return true; @@ -107,14 +119,12 @@ class _WorkoutFlowScreenState extends State { required int index, required WorkoutProvider provider, }) { - final slot = _slotForIndex(index); + final slot = _slotForIndex(index, provider: provider); if (slot == null) return false; if (index >= provider.currentExerciseLogs.length) return false; - final targetSets = (widget.programWeek?.isDeload == true) - ? (slot.sets - (widget.programWeek?.deloadSetReduction ?? 0)).clamp( - 1, - 99, - ) + final week = _resolvedProgramWeek(provider); + final targetSets = (week?.isDeload == true) + ? (slot.sets - (week?.deloadSetReduction ?? 0)).clamp(1, 99) : slot.sets; final logged = provider.currentExerciseLogs[index].sets.length; return logged < targetSets; @@ -122,9 +132,14 @@ class _WorkoutFlowScreenState extends State { void _initializeWorkout() { final provider = context.read(); + final programDay = _resolvedProgramDay(provider); + final programWeek = _resolvedProgramWeek(provider); if (provider.hasActiveWorkout) { - final slot = _slotForIndex(provider.currentExerciseIndex); + final slot = _slotForIndex( + provider.currentExerciseIndex, + provider: provider, + ); if (slot != null) { _restSeconds = slot.restSeconds; } @@ -132,13 +147,17 @@ class _WorkoutFlowScreenState extends State { return; } - if (widget.programDay != null) { - final exerciseIds = widget.programDay!.exercises + if (programDay != null) { + final exerciseIds = programDay.exercises .map((s) => s.exerciseId) .toList(); - provider.startWorkout(exerciseIds: exerciseIds); + provider.startWorkout( + exerciseIds: exerciseIds, + programDay: programDay, + programWeek: programWeek, + ); // Set initial rest time from first slot - final firstSlot = _slotForIndex(0); + final firstSlot = _slotForIndex(0, provider: provider); if (firstSlot != null) _restSeconds = firstSlot.restSeconds; _loadLastSessionData(); } else if (widget.routine != null) { @@ -1079,13 +1098,16 @@ class _WorkoutFlowScreenState extends State { // ==================== Program Meta Banner ==================== Widget _buildProgramMetaBanner(WorkoutProvider provider) { - if (widget.programDay == null || widget.programWeek == null) { + final week = _resolvedProgramWeek(provider); + if (_resolvedProgramDay(provider) == null || week == null) { return const SizedBox.shrink(); } - final slot = _slotForIndex(provider.currentExerciseIndex); + final slot = _slotForIndex( + provider.currentExerciseIndex, + provider: provider, + ); if (slot == null) return const SizedBox.shrink(); - final week = widget.programWeek!; final displaySets = week.isDeload ? (slot.sets - week.deloadSetReduction).clamp(1, 99) : slot.sets; @@ -1301,8 +1323,8 @@ class _WorkoutFlowScreenState extends State { void _completeSet() { final provider = context.read(); final currentIdx = provider.currentExerciseIndex; - final currentSlot = _slotForIndex(currentIdx); - final nextSlot = _slotForIndex(currentIdx + 1); + final currentSlot = _slotForIndex(currentIdx, provider: provider); + final nextSlot = _slotForIndex(currentIdx + 1, provider: provider); final set = WorkoutSet( weight: _currentWeight, @@ -1345,14 +1367,21 @@ class _WorkoutFlowScreenState extends State { provider.nextExercise(); _loadLastSessionData(); // Apply the next slot's rest time so the subsequent rest is correct - final newSlot = _slotForIndex(provider.currentExerciseIndex); + final newSlot = _slotForIndex( + provider.currentExerciseIndex, + provider: provider, + ); if (newSlot != null) setState(() => _restSeconds = newSlot.restSeconds); } else { // Detect if we just finished the last exercise in a superset group. // If the group still needs more sets, schedule a return after rest. final groupId = currentSlot?.supersetGroupId; if (groupId != null) { - final groupStart = _supersetGroupStart(currentIdx, groupId); + final groupStart = _supersetGroupStart( + currentIdx, + groupId, + provider: provider, + ); if (_supersetNeedsMoreSets( startIdx: groupStart, endIdx: currentIdx, @@ -1394,7 +1423,7 @@ class _WorkoutFlowScreenState extends State { final provider = context.read(); provider.goToExercise(returnIdx); _loadLastSessionData(); - final slot = _slotForIndex(returnIdx); + final slot = _slotForIndex(returnIdx, provider: provider); if (slot != null) setState(() => _restSeconds = slot.restSeconds); } diff --git a/workout-logger/lib/services/workout_provider.dart b/workout-logger/lib/services/workout_provider.dart index a583ece..94f2ba1 100644 --- a/workout-logger/lib/services/workout_provider.dart +++ b/workout-logger/lib/services/workout_provider.dart @@ -52,12 +52,15 @@ class WorkoutProvider extends ChangeNotifier { // Active workout state WorkoutSession? _activeSession; Routine? _activeRoutine; + ProgramDay? _activeProgramDay; + ProgramWeek? _activeProgramWeek; int _currentExerciseIndex = 0; List _currentExerciseLogs = []; DateTime? _workoutStartTime; static const String _draftKey = 'active_workout_draft'; static const int _draftSchemaVersion = 1; bool _draftRestoreInProgress = false; + Future _draftWriteQueue = Future.value(); // Getters List get sessions => _sessions; @@ -69,6 +72,8 @@ class WorkoutProvider extends ChangeNotifier { bool get hasActiveWorkout => _activeSession != null || _workoutStartTime != null; Routine? get activeRoutine => _activeRoutine; + ProgramDay? get activeProgramDay => _activeProgramDay; + ProgramWeek? get activeProgramWeek => _activeProgramWeek; int get currentExerciseIndex => _currentExerciseIndex; List get currentExerciseLogs => _currentExerciseLogs; DateTime? get workoutStartTime => _workoutStartTime; @@ -146,25 +151,38 @@ class WorkoutProvider extends ChangeNotifier { 'schemaVersion': _draftSchemaVersion, 'startTime': _workoutStartTime!.toIso8601String(), 'routineId': _activeRoutine?.id, + 'programDay': _activeProgramDay?.toJson(), + 'programWeek': _activeProgramWeek?.toJson(), 'currentExerciseIndex': _currentExerciseIndex, 'currentExerciseLogs': _currentExerciseLogs .map((log) => log.toJson()) .toList(), }); - try { - await _storage.saveSetting(_draftKey, draft); - } catch (e) { - debugPrint('Failed to persist active workout draft: $e'); - } + await _enqueueDraftWrite(() async { + try { + await _storage.saveSetting(_draftKey, draft); + } catch (e) { + debugPrint('Failed to persist active workout draft: $e'); + } + }); + } + + Future _enqueueDraftWrite(Future Function() writeOperation) { + _draftWriteQueue = _draftWriteQueue + .catchError((_) {}) + .then((_) => writeOperation()); + return _draftWriteQueue; } Future _clearDraft() async { - try { - await _storage.saveSetting(_draftKey, ''); - } catch (e) { - debugPrint('Failed to clear active workout draft: $e'); - } + await _enqueueDraftWrite(() async { + try { + await _storage.saveSetting(_draftKey, ''); + } catch (e) { + debugPrint('Failed to clear active workout draft: $e'); + } + }); } Future _restoreDraftIfAny() async { @@ -209,6 +227,22 @@ class WorkoutProvider extends ChangeNotifier { ) .toList(); + final programDayRaw = draft['programDay']; + ProgramDay? restoredProgramDay; + if (programDayRaw is Map) { + restoredProgramDay = ProgramDay.fromJson( + Map.from(programDayRaw), + ); + } + + final programWeekRaw = draft['programWeek']; + ProgramWeek? restoredProgramWeek; + if (programWeekRaw is Map) { + restoredProgramWeek = ProgramWeek.fromJson( + Map.from(programWeekRaw), + ); + } + final routineId = draft['routineId'] as String?; Routine? restoredRoutine; if (routineId != null) { @@ -227,6 +261,8 @@ class WorkoutProvider extends ChangeNotifier { _activeSession = null; _workoutStartTime = DateTime.parse(startTimeRaw); _activeRoutine = restoredRoutine; + _activeProgramDay = restoredProgramDay; + _activeProgramWeek = restoredProgramWeek; _currentExerciseLogs = restoredLogs; _currentExerciseIndex = indexFromDraft.clamp(0, maxIndex).toInt(); notifyListeners(); @@ -384,13 +420,20 @@ class WorkoutProvider extends ChangeNotifier { // ==================== WORKOUT FLOW ==================== /// Start a new workout with a routine - void startWorkout({Routine? routine, List? exerciseIds}) { + void startWorkout({ + Routine? routine, + List? exerciseIds, + ProgramDay? programDay, + ProgramWeek? programWeek, + }) { if (hasActiveWorkout) { throw WorkoutInProgressError(); } _workoutStartTime = DateTime.now(); _activeRoutine = routine; + _activeProgramDay = programDay; + _activeProgramWeek = programWeek; _currentExerciseIndex = 0; _currentExerciseLogs = []; @@ -407,17 +450,29 @@ class WorkoutProvider extends ChangeNotifier { Future startWorkoutSafely({ Routine? routine, List? exerciseIds, + ProgramDay? programDay, + ProgramWeek? programWeek, required Future Function() onConflict, }) async { if (!hasActiveWorkout) { - startWorkout(routine: routine, exerciseIds: exerciseIds); + startWorkout( + routine: routine, + exerciseIds: exerciseIds, + programDay: programDay, + programWeek: programWeek, + ); return true; } final action = await onConflict(); if (action == StartWorkoutConflictAction.discardAndStart) { await cancelWorkout(); - startWorkout(routine: routine, exerciseIds: exerciseIds); + startWorkout( + routine: routine, + exerciseIds: exerciseIds, + programDay: programDay, + programWeek: programWeek, + ); return true; } @@ -542,6 +597,8 @@ class WorkoutProvider extends ChangeNotifier { // Clear active workout state _activeSession = null; _activeRoutine = null; + _activeProgramDay = null; + _activeProgramWeek = null; _currentExerciseIndex = 0; _currentExerciseLogs = []; _workoutStartTime = null; @@ -554,6 +611,8 @@ class WorkoutProvider extends ChangeNotifier { Future cancelWorkout() async { _activeSession = null; _activeRoutine = null; + _activeProgramDay = null; + _activeProgramWeek = null; _currentExerciseIndex = 0; _currentExerciseLogs = []; _workoutStartTime = null; diff --git a/workout-logger/test/program_detail_screen_test.dart b/workout-logger/test/program_detail_screen_test.dart new file mode 100644 index 0000000..25acc1f --- /dev/null +++ b/workout-logger/test/program_detail_screen_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/programs/program_detail_screen.dart'; +import 'package:repforge/screens/workout_flow_screen.dart'; +import 'package:repforge/services/managers/program_manager.dart'; +import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/workout_provider.dart'; + +import 'test_utils/mock_storage_service.dart'; + +Widget createTestWidget({ + required Widget child, + required WorkoutProvider provider, + required SettingsProvider settingsProvider, +}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: provider), + ChangeNotifierProvider.value(value: settingsProvider), + ], + child: MaterialApp(home: child), + ); +} + +void main() { + group('ProgramDetailScreen Widget Tests', () { + late MockStorageService mockStorage; + late WorkoutProvider provider; + late SettingsProvider settingsProvider; + + setUp(() async { + mockStorage = MockStorageService(); + provider = WorkoutProvider( + mockStorage, + programManager: ProgramManager(mockStorage), + ); + settingsProvider = SettingsProvider(mockStorage); + await provider.init(); + await settingsProvider.init(); + }); + + testWidgets('resume uses active workout program context', ( + WidgetTester tester, + ) async { + final resumeDay = ProgramDay( + id: 'resume_day', + name: 'Resume Day', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 6, + maxReps: 6, + restSeconds: 120, + ), + ], + ); + final resumeWeek = ProgramWeek(weekNumber: 2, days: [resumeDay]); + + final tappedDay = ProgramDay( + id: 'tapped_day', + name: 'Tapped Day', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'squat', + sets: 2, + minReps: 12, + maxReps: 12, + restSeconds: 45, + ), + ], + ); + final tappedWeek = ProgramWeek(weekNumber: 1, days: [tappedDay]); + final program = TrainingProgram( + id: 'program_1', + name: 'Program Under Test', + totalWeeks: 1, + phases: const [], + weeks: [tappedWeek], + ); + + provider.startWorkout( + exerciseIds: const ['bench_press'], + programDay: resumeDay, + programWeek: resumeWeek, + ); + + await tester.pumpWidget( + createTestWidget( + child: ProgramDetailScreen(program: program), + provider: provider, + settingsProvider: settingsProvider, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('W1')); + await tester.pumpAndSettle(); + + final startButton = find.text('Start Tapped Day'); + await tester.ensureVisible(startButton); + await tester.tap(startButton); + await tester.pumpAndSettle(); + + expect(find.text('Workout already in progress'), findsOneWidget); + + await tester.tap(find.text('Resume')); + await tester.pumpAndSettle(); + + expect(find.byType(WorkoutFlowScreen), findsOneWidget); + expect(find.textContaining('120s rest'), findsOneWidget); + expect(find.textContaining('45s rest'), findsNothing); + }); + }); +} diff --git a/workout-logger/test/test_utils/mock_storage_service.dart b/workout-logger/test/test_utils/mock_storage_service.dart index d7d24af..6444969 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -24,6 +24,8 @@ class MockStorageService implements IStorageService { bool saveCustomExerciseCalled = false; Exercise? lastSavedExercise; int saveSettingCallCount = 0; + Duration saveSettingDelay = Duration.zero; + Duration Function(String key, String value)? saveSettingDelayResolver; // Public getters for test assertions List get customExercises => _customExercises; @@ -223,6 +225,11 @@ class MockStorageService implements IStorageService { @override Future saveSetting(String key, String value) async { + final delay = + saveSettingDelayResolver?.call(key, value) ?? saveSettingDelay; + if (delay > Duration.zero) { + await Future.delayed(delay); + } saveSettingCallCount++; _settings[key] = value; } diff --git a/workout-logger/test/workout_provider_test.dart b/workout-logger/test/workout_provider_test.dart index 6306918..db01f66 100644 --- a/workout-logger/test/workout_provider_test.dart +++ b/workout-logger/test/workout_provider_test.dart @@ -348,6 +348,52 @@ void main() { expect(sets.length, equals(1)); }); + test('persists program context in draft payload', () async { + final day = ProgramDay( + id: 'day_push', + name: 'Push', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 120, + ), + ], + ); + final week = ProgramWeek( + weekNumber: 3, + isDeload: true, + deloadIntensityFactor: 0.9, + deloadSetReduction: 1, + days: [day], + ); + + provider.startWorkout( + exerciseIds: const ['bench_press'], + programDay: day, + programWeek: week, + ); + provider.addSet(WorkoutSet(weight: 100, reps: 5)); + + await flushAsync(); + + final rawDraft = mockStorage.settings[draftKey]; + expect(rawDraft, isNotNull); + final draft = Map.from(jsonDecode(rawDraft!) as Map); + + final draftProgramDay = Map.from( + draft['programDay'] as Map, + ); + final draftProgramWeek = Map.from( + draft['programWeek'] as Map, + ); + + expect(draftProgramDay['id'], equals(day.id)); + expect(draftProgramWeek['weekNumber'], equals(week.weekNumber)); + }); + test('persists currentExerciseIndex when navigating', () async { provider.startWorkout(exerciseIds: const ['bench_press', 'squat']); @@ -383,18 +429,53 @@ void main() { expect(mockStorage.settings[draftKey], equals('')); }); + test( + 'clear waits for pending draft writes to avoid stale payload', + () async { + mockStorage.saveSettingDelayResolver = (_, value) { + return value.isEmpty + ? const Duration(milliseconds: 1) + : const Duration(milliseconds: 30); + }; + + provider.startWorkout(exerciseIds: const ['bench_press']); + provider.addSet(WorkoutSet(weight: 80, reps: 8)); + + await provider.cancelWorkout(); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(mockStorage.settings[draftKey], equals('')); + }, + ); + test('restores draft during init without extra writes', () async { final routine = Routine( id: 'routine_1', name: 'Push Day', exerciseIds: const ['bench_press'], ); + final day = ProgramDay( + id: 'day_1', + name: 'Push', + exercises: [ + ProgramExerciseSlot( + exerciseId: 'bench_press', + sets: 4, + minReps: 6, + maxReps: 10, + restSeconds: 90, + ), + ], + ); + final week = ProgramWeek(weekNumber: 1, days: [day]); await mockStorage.saveRoutine(routine); final draft = jsonEncode({ 'schemaVersion': 1, 'startTime': DateTime(2026, 4, 26, 18, 43, 11).toIso8601String(), 'routineId': routine.id, + 'programDay': day.toJson(), + 'programWeek': week.toJson(), 'currentExerciseIndex': 0, 'currentExerciseLogs': [ ExerciseLog( @@ -413,6 +494,11 @@ void main() { expect(restoringProvider.hasActiveWorkout, isTrue); expect(restoringProvider.activeRoutine?.id, equals(routine.id)); + expect(restoringProvider.activeProgramDay?.id, equals(day.id)); + expect( + restoringProvider.activeProgramWeek?.weekNumber, + equals(week.weekNumber), + ); expect(restoringProvider.currentExerciseLogs.length, equals(1)); expect( restoringProvider.currentExerciseLogs.first.sets.length,