diff --git a/workout-logger/lib/screens/home_screen.dart b/workout-logger/lib/screens/home_screen.dart index 56b060d..ba453a7 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', @@ -389,14 +396,20 @@ 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'), ), ], ), const SizedBox(height: AppSpacing.sm), - ...recentSessions.map((session) => _RecentWorkoutCard(session: session)), + ...recentSessions.map( + (session) => _RecentWorkoutCard(session: session), + ), ], ); } @@ -405,10 +418,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 +439,9 @@ class DashboardTab extends StatelessWidget { label: 'Exercises', onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (_) => const ExerciseLibraryScreen()), + MaterialPageRoute( + builder: (_) => const ExerciseLibraryScreen(), + ), ), ), ), @@ -439,25 +451,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 +506,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 +679,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..aeb71d6 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,46 @@ 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(), + programDay: day, + programWeek: week, + 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) { + final resumeProgramDay = provider.hasActiveWorkout + ? provider.activeProgramDay + : day; + final resumeProgramWeek = provider.hasActiveWorkout + ? provider.activeProgramWeek + : week; + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WorkoutFlowScreen( + programDay: resumeProgramDay, + programWeek: resumeProgramWeek, + ), + ), + ); + } + } + // ── Header ────────────────────────────────────────────────────────────── Widget _buildHeader() { @@ -234,8 +275,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 +452,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 +589,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 +657,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 +713,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..2890b3d 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; @@ -61,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; @@ -84,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; @@ -105,11 +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; @@ -117,13 +132,32 @@ class _WorkoutFlowScreenState extends State { void _initializeWorkout() { final provider = context.read(); + final programDay = _resolvedProgramDay(provider); + final programWeek = _resolvedProgramWeek(provider); - if (widget.programDay != null) { - final exerciseIds = - widget.programDay!.exercises.map((s) => s.exerciseId).toList(); - provider.startWorkout(exerciseIds: exerciseIds); + if (provider.hasActiveWorkout) { + final slot = _slotForIndex( + provider.currentExerciseIndex, + provider: provider, + ); + if (slot != null) { + _restSeconds = slot.restSeconds; + } + _loadLastSessionData(); + return; + } + + if (programDay != null) { + final exerciseIds = programDay.exercises + .map((s) => s.exerciseId) + .toList(); + 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) { @@ -149,7 +183,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 +220,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 +251,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); } @@ -360,6 +402,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); @@ -401,7 +448,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, @@ -1051,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; @@ -1120,7 +1170,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 +1184,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) ...[ @@ -1265,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, @@ -1300,7 +1358,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 && @@ -1308,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, @@ -1357,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); } @@ -1459,22 +1525,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..94f2ba1 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; @@ -43,9 +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; @@ -57,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; @@ -78,6 +95,7 @@ class WorkoutProvider extends ChangeNotifier { await _storage.init(); await loadAllData(); await _trainAllGrowthModels(); + await _restoreDraftIfAny(); } Future loadAllData() async { @@ -119,6 +137,143 @@ 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, + 'programDay': _activeProgramDay?.toJson(), + 'programWeek': _activeProgramWeek?.toJson(), + 'currentExerciseIndex': _currentExerciseIndex, + 'currentExerciseLogs': _currentExerciseLogs + .map((log) => log.toJson()) + .toList(), + }); + + 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 { + await _enqueueDraftWrite(() 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 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) { + 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; + } + } + // ==================== EXERCISE HELPERS ==================== Exercise? getExercise(String id) { @@ -219,20 +374,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; } @@ -253,9 +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 = []; @@ -266,6 +444,39 @@ class WorkoutProvider extends ChangeNotifier { } notifyListeners(); + unawaited(_persistDraft()); + } + + Future startWorkoutSafely({ + Routine? routine, + List? exerciseIds, + ProgramDay? programDay, + ProgramWeek? programWeek, + required Future Function() onConflict, + }) async { + if (!hasActiveWorkout) { + 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, + programDay: programDay, + programWeek: programWeek, + ); + return true; + } + + return false; } /// Get current exercise being performed @@ -297,6 +508,7 @@ class WorkoutProvider extends ChangeNotifier { notes: currentLog.notes, ); notifyListeners(); + unawaited(_persistDraft()); } } @@ -312,6 +524,7 @@ class WorkoutProvider extends ChangeNotifier { notes: currentLog.notes, ); notifyListeners(); + unawaited(_persistDraft()); } } } @@ -321,6 +534,7 @@ class WorkoutProvider extends ChangeNotifier { if (_currentExerciseIndex < _currentExerciseLogs.length - 1) { _currentExerciseIndex++; notifyListeners(); + unawaited(_persistDraft()); return true; } return false; // No more exercises @@ -331,6 +545,7 @@ class WorkoutProvider extends ChangeNotifier { if (_currentExerciseIndex > 0) { _currentExerciseIndex--; notifyListeners(); + unawaited(_persistDraft()); return true; } return false; @@ -338,9 +553,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 +583,7 @@ class WorkoutProvider extends ChangeNotifier { ); await _storage.saveWorkoutSession(session); + await _clearDraft(); _sessions.insert(0, session); // Update growth models for performed exercises @@ -378,6 +597,8 @@ class WorkoutProvider extends ChangeNotifier { // Clear active workout state _activeSession = null; _activeRoutine = null; + _activeProgramDay = null; + _activeProgramWeek = null; _currentExerciseIndex = 0; _currentExerciseLogs = []; _workoutStartTime = null; @@ -387,13 +608,16 @@ class WorkoutProvider extends ChangeNotifier { } /// Cancel workout without saving - void cancelWorkout() { + Future cancelWorkout() async { _activeSession = null; _activeRoutine = null; + _activeProgramDay = null; + _activeProgramWeek = null; _currentExerciseIndex = 0; _currentExerciseLogs = []; _workoutStartTime = null; notifyListeners(); + await _clearDraft(); } // ==================== RECOMMENDATIONS ==================== @@ -699,7 +923,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/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 8f730e5..6444969 100644 --- a/workout-logger/test/test_utils/mock_storage_service.dart +++ b/workout-logger/test/test_utils/mock_storage_service.dart @@ -23,12 +23,16 @@ 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; List get sessions => _sessions; List get routines => _routines; List get targets => _targets; + Map get settings => _settings; // Test helpers void addMockCustomExercise(Exercise exercise) { @@ -119,8 +123,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 +225,12 @@ 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 473f349..db01f66 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,257 @@ 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 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']); + + 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( + '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( + 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.activeProgramDay?.id, equals(day.id)); + expect( + restoringProvider.activeProgramWeek?.weekNumber, + equals(week.weekNumber), + ); + 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); + }); + }); }); }