From 7c4e6847fd568af3238d27abebb364d378ac6074 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:06 +0700 Subject: [PATCH 1/8] feat(workouts-domain): add abandon workout contract --- .../domain/repositories/workout_execution_repository.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/features/workouts/execution/domain/repositories/workout_execution_repository.dart b/lib/features/workouts/execution/domain/repositories/workout_execution_repository.dart index 0dcd5fed..67603eb8 100644 --- a/lib/features/workouts/execution/domain/repositories/workout_execution_repository.dart +++ b/lib/features/workouts/execution/domain/repositories/workout_execution_repository.dart @@ -31,6 +31,9 @@ abstract interface class WorkoutExecutionRepository { double? weightUsed, }); + /// Abandons the current workout and resets it back to assigned. + Future> abandonWorkout(int userWorkoutId); + /// Completes the current workout. Future> completeWorkout(int userWorkoutId); } From 2a0ec6a72acf44093c22d995784fef55dc082368 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:15 +0700 Subject: [PATCH 2/8] feat(workouts-data): implement abandon workout command --- .../workouts/data/remote/workouts_api_client.dart | 6 ++++++ .../workout_execution_repository_impl.dart | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/features/workouts/data/remote/workouts_api_client.dart b/lib/features/workouts/data/remote/workouts_api_client.dart index 9e648c2b..59bd7d57 100644 --- a/lib/features/workouts/data/remote/workouts_api_client.dart +++ b/lib/features/workouts/data/remote/workouts_api_client.dart @@ -63,4 +63,10 @@ abstract class WorkoutsApiClient { Future completeWorkout( @Path('userWorkout') int userWorkoutId, ); + + /// Abandons the current workout and resets it back to assigned. + @POST('${ApiPaths.workouts}/{userWorkout}/abandon') + Future abandonWorkout( + @Path('userWorkout') int userWorkoutId, + ); } diff --git a/lib/features/workouts/execution/data/repositories/workout_execution_repository_impl.dart b/lib/features/workouts/execution/data/repositories/workout_execution_repository_impl.dart index 6cbbbb32..1f0639f0 100644 --- a/lib/features/workouts/execution/data/repositories/workout_execution_repository_impl.dart +++ b/lib/features/workouts/execution/data/repositories/workout_execution_repository_impl.dart @@ -163,6 +163,20 @@ final class WorkoutExecutionRepositoryImpl implements WorkoutExecutionRepository return Result.failure(UnknownWorkoutsFailure(parentException: e, stackTrace: s)); } } + + @override + Future> abandonWorkout(int userWorkoutId) async { + try { + await _apiClient.abandonWorkout(userWorkoutId); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toWorkoutsFailure()); + } catch (e, s) { + _logger.e('AbandonWorkout failed with unexpected error', e, s); + return Result.failure(UnknownWorkoutsFailure(parentException: e, stackTrace: s)); + } + } } WorkoutLoadAdjustment? _mapLoadAdjustment(SaveExerciseResultAdjustmentDto? adjustment) { From 218890b25564be059d8a1481e431aa29182043bd Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:24 +0700 Subject: [PATCH 3/8] test(workouts-repo): add abandon workout repository coverage --- ...orkout_execution_repository_impl_test.dart | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/features/workouts/execution/data/repositories/workout_execution_repository_impl_test.dart b/test/features/workouts/execution/data/repositories/workout_execution_repository_impl_test.dart index 28c3d5d9..fe5f85e1 100644 --- a/test/features/workouts/execution/data/repositories/workout_execution_repository_impl_test.dart +++ b/test/features/workouts/execution/data/repositories/workout_execution_repository_impl_test.dart @@ -507,5 +507,58 @@ void main() { verifyNoMoreInteractions(apiClient); }); }); + + group('abandonWorkout', () { + test('returns success(null) when api succeeds', () async { + // Arrange + when(apiClient.abandonWorkout(userWorkoutId)).thenAnswer((_) async {}); + + // Act + final result = await repository.abandonWorkout(userWorkoutId); + + // Assert + expect(result.isSuccess, isTrue); + verify(apiClient.abandonWorkout(userWorkoutId)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns WorkoutsRequestFailure when api request fails', () async { + // Arrange + final exception = createWorkoutsDioBadResponseException( + path: '/workouts/$userWorkoutId/abandon', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.abandonWorkout(userWorkoutId)).thenThrow(exception); + + // Act + final result = await repository.abandonWorkout(userWorkoutId); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + + verify(apiClient.abandonWorkout(userWorkoutId)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownWorkoutsFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.abandonWorkout(userWorkoutId)).thenThrow(exception); + + // Act + final result = await repository.abandonWorkout(userWorkoutId); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.abandonWorkout(userWorkoutId)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); }); } From a8463da0aebd1be01f57c068ae8f1eb9501de6e5 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:32 +0700 Subject: [PATCH 4/8] feat(workouts): add abandon workout cubit flow --- .../cubits/workout_execution_cubit.dart | 81 +++++++++---------- .../cubits/workout_execution_state.dart | 1 + 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/features/workouts/execution/presentation/cubits/workout_execution_cubit.dart b/lib/features/workouts/execution/presentation/cubits/workout_execution_cubit.dart index ff48c7c0..d349f8c0 100644 --- a/lib/features/workouts/execution/presentation/cubits/workout_execution_cubit.dart +++ b/lib/features/workouts/execution/presentation/cubits/workout_execution_cubit.dart @@ -21,7 +21,11 @@ final class WorkoutExecutionCubit extends Cubit { WorkoutExecutionCubit(this._repository) : super(const WorkoutExecutionState()); bool get _isBusy => - state.isStarting || state.isAdvancingWarmup || state.isSubmittingResult || state.isCompleting; + state.isStarting || + state.isAdvancingWarmup || + state.isSubmittingResult || + state.isCompleting || + state.isAbandoning; /// Starts workout execution for the given [userWorkoutId] and [entryMode]. Future startExecution( @@ -36,6 +40,7 @@ final class WorkoutExecutionCubit extends Cubit { isAdvancingWarmup: false, isSubmittingResult: false, isCompleting: false, + isAbandoning: false, userWorkoutId: userWorkoutId, currentStep: null, failure: null, @@ -156,46 +161,6 @@ final class WorkoutExecutionCubit extends Cubit { } } - /// Completes warmup early and requests the page to return to details. - Future exitWarmupToDetails() async { - final userWorkoutId = state.userWorkoutId; - final currentStep = state.currentStep; - if (_isBusy || - state.isCompleted || - userWorkoutId == null || - currentStep is! WorkoutWarmupStep) { - return; - } - - emit( - state.copyWith( - isAdvancingWarmup: true, - failure: null, - ), - ); - - final result = await _repository.skipWarmup(userWorkoutId); - if (isClosed) return; - - switch (result) { - case Success(): - emit( - state.copyWith( - isAdvancingWarmup: false, - failure: null, - shouldPopToDetails: true, - ), - ); - case Failure(:final error): - emit( - state.copyWith( - isAdvancingWarmup: false, - failure: error, - ), - ); - } - } - /// Submits a reaction for the current workout exercise. Future submitReaction( WorkoutExerciseReaction reaction, { @@ -256,6 +221,40 @@ final class WorkoutExecutionCubit extends Cubit { } } + /// Abandons the current workout and requests the page to return to details. + Future abandonWorkout() async { + final userWorkoutId = state.userWorkoutId; + if (_isBusy || state.isCompleted || userWorkoutId == null) return; + + emit( + state.copyWith( + isAbandoning: true, + failure: null, + ), + ); + + final result = await _repository.abandonWorkout(userWorkoutId); + if (isClosed) return; + + switch (result) { + case Success(): + emit( + state.copyWith( + isAbandoning: false, + failure: null, + shouldPopToDetails: true, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isAbandoning: false, + failure: error, + ), + ); + } + } + /// Completes the current workout. Future completeWorkout() async { final userWorkoutId = state.userWorkoutId; diff --git a/lib/features/workouts/execution/presentation/cubits/workout_execution_state.dart b/lib/features/workouts/execution/presentation/cubits/workout_execution_state.dart index be695841..80173663 100644 --- a/lib/features/workouts/execution/presentation/cubits/workout_execution_state.dart +++ b/lib/features/workouts/execution/presentation/cubits/workout_execution_state.dart @@ -9,6 +9,7 @@ abstract class WorkoutExecutionState with _$WorkoutExecutionState { @Default(false) bool isAdvancingWarmup, @Default(false) bool isSubmittingResult, @Default(false) bool isCompleting, + @Default(false) bool isAbandoning, int? userWorkoutId, WorkoutExecutionStep? currentStep, WorkoutsFailure? failure, From df6cd95add559f07b1d68251de1031cbe9c855c2 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:39 +0700 Subject: [PATCH 5/8] test(workouts): add abandon workout cubit coverage --- .../cubits/workout_execution_cubit_test.dart | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/test/features/workouts/execution/presentation/cubits/workout_execution_cubit_test.dart b/test/features/workouts/execution/presentation/cubits/workout_execution_cubit_test.dart index b848ef94..79432689 100644 --- a/test/features/workouts/execution/presentation/cubits/workout_execution_cubit_test.dart +++ b/test/features/workouts/execution/presentation/cubits/workout_execution_cubit_test.dart @@ -154,21 +154,21 @@ void main() { ); blocTest( - 'exitWarmupToDetails sets shouldPopToDetails to true on success', + 'abandonWorkout sets shouldPopToDetails to true on success', setUp: () => when( - repository.skipWarmup(1), - ).thenAnswer((_) async => Success(exerciseStep)), + repository.abandonWorkout(1), + ).thenAnswer((_) async => const Success(null)), build: () => cubit, seed: () => WorkoutExecutionState( userWorkoutId: userWorkoutId, currentStep: warmupStep, ), - act: (cubit) => cubit.exitWarmupToDetails(), + act: (cubit) => cubit.abandonWorkout(), expect: () => [ WorkoutExecutionState( userWorkoutId: userWorkoutId, currentStep: warmupStep, - isAdvancingWarmup: true, + isAbandoning: true, ), WorkoutExecutionState( userWorkoutId: userWorkoutId, @@ -176,7 +176,62 @@ void main() { shouldPopToDetails: true, ), ], - verify: (_) => verify(repository.skipWarmup(1)).called(1), + verify: (_) => verify(repository.abandonWorkout(1)).called(1), + ); + + blocTest( + 'abandonWorkout emits failure and preserves current step context when repository fails', + setUp: () => when( + repository.abandonWorkout(1), + ).thenAnswer((_) async => const Failure(workoutsFailure)), + build: () => cubit, + seed: () => WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + ), + act: (cubit) => cubit.abandonWorkout(), + expect: () => [ + WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + isAbandoning: true, + ), + WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + failure: workoutsFailure, + ), + ], + verify: (_) => verify(repository.abandonWorkout(1)).called(1), + ); + + blocTest( + 'abandonWorkout emits inProgress only once when called twice', + setUp: () => when( + repository.abandonWorkout(1), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + seed: () => WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + ), + act: (cubit) { + cubit.abandonWorkout(); + cubit.abandonWorkout(); + }, + expect: () => [ + WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + isAbandoning: true, + ), + WorkoutExecutionState( + userWorkoutId: userWorkoutId, + currentStep: warmupStep, + shouldPopToDetails: true, + ), + ], + verify: (_) => verify(repository.abandonWorkout(1)).called(1), ); blocTest( From 833fc29fe139aaf746ea212aaacdd1fbefa7b36a Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:46 +0700 Subject: [PATCH 6/8] feat(workouts-ui): use abandon flow for workout close and warmup exit --- lib/core/constants/app_strings.dart | 6 +- .../pages/workout_execution_page.dart | 74 +++++++++++++++---- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index b40cdf2b..b3d94994 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -290,10 +290,10 @@ abstract final class AppStrings { static const workoutExecutionReactionPrompt = 'Как ощущался данный подход?'; static const workoutExecutionNextWarmupButton = 'Далее'; static const workoutExecutionFinishWarmupButton = 'Завершить разминку'; - static const workoutExecutionExitTitle = 'Завершить тренировку'; + static const workoutExecutionExitTitle = 'Сбросить тренировку'; static const workoutExecutionExitDescription = - 'Вы действительно хотите завершить тренировку? Если Вы захотите ее продолжить, придется начать сначала'; - static const workoutExecutionExitPrimary = 'Завершить'; + 'Вы действительно хотите сбросить активную тренировку? Если Вы захотите ее продолжить, придется начать сначала'; + static const workoutExecutionExitPrimary = 'Сбросить'; static const workoutExecutionExitSecondary = 'Отменить'; static const workoutExecutionCompletedTitle = 'Тренировка завершена'; static const workoutExecutionCompletedDescription = diff --git a/lib/features/workouts/execution/presentation/pages/workout_execution_page.dart b/lib/features/workouts/execution/presentation/pages/workout_execution_page.dart index 9faa31db..27617ac8 100644 --- a/lib/features/workouts/execution/presentation/pages/workout_execution_page.dart +++ b/lib/features/workouts/execution/presentation/pages/workout_execution_page.dart @@ -53,6 +53,7 @@ class _WorkoutExecutionPageState extends State { int _remainingRestSeconds = _initialRestSeconds; int? _activeExerciseId; WorkoutExerciseReaction? _selectedReaction; + bool _isExitDialogOpen = false; @override void dispose() { @@ -76,7 +77,7 @@ class _WorkoutExecutionPageState extends State { leading: isWarmupScreen ? AppBackButton( onPressed: _canHandleUserAction(state) - ? () => context.read().exitWarmupToDetails() + ? () => context.read().abandonWorkout() : null, ) : WorkoutCloseButton( @@ -133,7 +134,8 @@ class _WorkoutExecutionPageState extends State { return !state.isStarting && !state.isAdvancingWarmup && !state.isSubmittingResult && - !state.isCompleting; + !state.isCompleting && + !state.isAbandoning; } Widget _buildStateSection(BuildContext context, WorkoutExecutionState state) { @@ -192,7 +194,9 @@ class _WorkoutExecutionPageState extends State { ) { final textTheme = AppTextTheme.of(context); final colorTheme = AppColorTheme.of(context); - final buttonState = state.isAdvancingWarmup ? ButtonState.disabled : ButtonState.enabled; + final buttonState = state.isAdvancingWarmup || state.isAbandoning + ? ButtonState.disabled + : ButtonState.enabled; final description = step.durationSeconds > 0 ? '${step.description}\n${step.durationSeconds} сек.' : step.description; @@ -296,7 +300,7 @@ class _WorkoutExecutionPageState extends State { Align( child: WorkoutReactionPicker( selectedReaction: _selectedReaction, - isEnabled: !state.isSubmittingResult && !state.isCompleting, + isEnabled: !state.isSubmittingResult && !state.isCompleting && !state.isAbandoning, onSelected: (reaction) => _handleReactionSelected(context, reaction), ), ), @@ -313,6 +317,7 @@ class _WorkoutExecutionPageState extends State { final adjustment = cubit.consumePendingAdjustment(); if (state.shouldPopToDetails) { + _closeExitDialogIfOpen(); cubit.clearPopToDetails(); if (!mounted) return; if (Navigator.canPop(context)) { @@ -325,6 +330,7 @@ class _WorkoutExecutionPageState extends State { final failure = state.failure; if (failure != null && state.currentStep != null) { + _closeExitDialogIfOpen(); await showAppFeedbackDialog( context, title: AppStrings.feedbackErrorTitle, @@ -443,22 +449,39 @@ class _WorkoutExecutionPageState extends State { } Future _showExitDialog(BuildContext context) { + if (_isExitDialogOpen) return Future.value(); + + _isExitDialogOpen = true; + final cubit = context.read(); return showAppActionDialog( context, title: AppStrings.workoutExecutionExitTitle, description: AppStrings.workoutExecutionExitDescription, - primaryAction: MainButton( - onPressed: () { - context.pop(); - context.read().completeWorkout(); - }, - child: const Text(AppStrings.workoutExecutionExitPrimary), + primaryAction: BlocProvider.value( + value: cubit, + child: BlocBuilder( + builder: (context, state) { + return MainButton( + state: state.isAbandoning ? ButtonState.loading : ButtonState.enabled, + onPressed: context.read().abandonWorkout, + child: const Text(AppStrings.workoutExecutionExitPrimary), + ); + }, + ), ), - secondaryAction: SecondaryButton( - onPressed: () => context.pop(), - child: const Text(AppStrings.workoutExecutionExitSecondary), + secondaryAction: BlocProvider.value( + value: cubit, + child: BlocBuilder( + builder: (context, state) { + return SecondaryButton( + state: state.isAbandoning ? ButtonState.disabled : ButtonState.enabled, + onPressed: _closeExitDialogIfOpen, + child: const Text(AppStrings.workoutExecutionExitSecondary), + ); + }, + ), ), - ); + ).whenComplete(() => _isExitDialogOpen = false); } Future _showCompletedDialog(BuildContext context) { @@ -475,6 +498,29 @@ class _WorkoutExecutionPageState extends State { ), ); } + + void _closeExitDialogIfOpen() { + if (!_isExitDialogOpen || !mounted) return; + + final navigator = Navigator.of(context, rootNavigator: true); + if (!navigator.canPop()) { + _isExitDialogOpen = false; + return; + } + + Route? topRoute; + navigator.popUntil((route) { + topRoute = route; + return true; + }); + if (topRoute is! PopupRoute) { + _isExitDialogOpen = false; + return; + } + + navigator.pop(); + _isExitDialogOpen = false; + } } String _buildAdjustmentMessage(WorkoutLoadAdjustment adjustment) { From bd95e9e1d90250fe17587a674c4fbb54f7807a2b Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:50:54 +0700 Subject: [PATCH 7/8] docs: update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa26c49..af5402e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workouts overview now reflects active workout state: started workouts open as a continue flow, while starting a different assigned workout is blocked with a dedicated dialog when another workout is already active. - Workout details CTAs now open a fullscreen workout execution flow above the shell, hiding the bottom navigation bar while the user completes warmups and training steps. - Workout execution now captures used weight before saving exercise results, shows load-adjustment feedback from backend, and displays exercise instructions with sets, reps, and current weight. +- Workout execution close actions now abandon the active workout through the new backend command instead of completing it, so warmup back and the in-workout close flow both reset the started workout back to assigned. - Workouts overview and details screens now reuse a shared `WorkoutCard` widget instead of maintaining duplicated card implementations. - Workouts overview and details app bars now reuse a dedicated `appBarTitle` text token instead of local per-page style overrides. - Authenticated tests catalog cards now open the real test attempt flow instead of the debug screen. From 2bcf23c4509d8c7dd863be9772fa7961f3dfd248 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 13:10:55 +0700 Subject: [PATCH 8/8] feat(constants): change strings for exit workout dialog --- lib/core/constants/app_strings.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index b3d94994..b40cdf2b 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -290,10 +290,10 @@ abstract final class AppStrings { static const workoutExecutionReactionPrompt = 'Как ощущался данный подход?'; static const workoutExecutionNextWarmupButton = 'Далее'; static const workoutExecutionFinishWarmupButton = 'Завершить разминку'; - static const workoutExecutionExitTitle = 'Сбросить тренировку'; + static const workoutExecutionExitTitle = 'Завершить тренировку'; static const workoutExecutionExitDescription = - 'Вы действительно хотите сбросить активную тренировку? Если Вы захотите ее продолжить, придется начать сначала'; - static const workoutExecutionExitPrimary = 'Сбросить'; + 'Вы действительно хотите завершить тренировку? Если Вы захотите ее продолжить, придется начать сначала'; + static const workoutExecutionExitPrimary = 'Завершить'; static const workoutExecutionExitSecondary = 'Отменить'; static const workoutExecutionCompletedTitle = 'Тренировка завершена'; static const workoutExecutionCompletedDescription =