Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions lib/features/workouts/data/remote/workouts_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,10 @@ abstract class WorkoutsApiClient {
Future<void> completeWorkout(
@Path('userWorkout') int userWorkoutId,
);

/// Abandons the current workout and resets it back to assigned.
@POST('${ApiPaths.workouts}/{userWorkout}/abandon')
Future<void> abandonWorkout(
@Path('userWorkout') int userWorkoutId,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ final class WorkoutExecutionRepositoryImpl implements WorkoutExecutionRepository
return Result.failure(UnknownWorkoutsFailure(parentException: e, stackTrace: s));
}
}

@override
Future<Result<void, WorkoutsFailure>> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ abstract interface class WorkoutExecutionRepository {
double? weightUsed,
});

/// Abandons the current workout and resets it back to assigned.
Future<Result<void, WorkoutsFailure>> abandonWorkout(int userWorkoutId);

/// Completes the current workout.
Future<Result<void, WorkoutsFailure>> completeWorkout(int userWorkoutId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ final class WorkoutExecutionCubit extends Cubit<WorkoutExecutionState> {
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<void> startExecution(
Expand All @@ -36,6 +40,7 @@ final class WorkoutExecutionCubit extends Cubit<WorkoutExecutionState> {
isAdvancingWarmup: false,
isSubmittingResult: false,
isCompleting: false,
isAbandoning: false,
userWorkoutId: userWorkoutId,
currentStep: null,
failure: null,
Expand Down Expand Up @@ -156,46 +161,6 @@ final class WorkoutExecutionCubit extends Cubit<WorkoutExecutionState> {
}
}

/// Completes warmup early and requests the page to return to details.
Future<void> 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<void> submitReaction(
WorkoutExerciseReaction reaction, {
Expand Down Expand Up @@ -256,6 +221,40 @@ final class WorkoutExecutionCubit extends Cubit<WorkoutExecutionState> {
}
}

/// Abandons the current workout and requests the page to return to details.
Future<void> 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<void> completeWorkout() async {
final userWorkoutId = state.userWorkoutId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
int _remainingRestSeconds = _initialRestSeconds;
int? _activeExerciseId;
WorkoutExerciseReaction? _selectedReaction;
bool _isExitDialogOpen = false;

@override
void dispose() {
Expand All @@ -76,7 +77,7 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
leading: isWarmupScreen
? AppBackButton(
onPressed: _canHandleUserAction(state)
? () => context.read<WorkoutExecutionCubit>().exitWarmupToDetails()
? () => context.read<WorkoutExecutionCubit>().abandonWorkout()
: null,
)
: WorkoutCloseButton(
Expand Down Expand Up @@ -133,7 +134,8 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
return !state.isStarting &&
!state.isAdvancingWarmup &&
!state.isSubmittingResult &&
!state.isCompleting;
!state.isCompleting &&
!state.isAbandoning;
}

Widget _buildStateSection(BuildContext context, WorkoutExecutionState state) {
Expand Down Expand Up @@ -192,7 +194,9 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
) {
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;
Expand Down Expand Up @@ -296,7 +300,7 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
Align(
child: WorkoutReactionPicker(
selectedReaction: _selectedReaction,
isEnabled: !state.isSubmittingResult && !state.isCompleting,
isEnabled: !state.isSubmittingResult && !state.isCompleting && !state.isAbandoning,
onSelected: (reaction) => _handleReactionSelected(context, reaction),
),
),
Expand All @@ -313,6 +317,7 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
final adjustment = cubit.consumePendingAdjustment();

if (state.shouldPopToDetails) {
_closeExitDialogIfOpen();
cubit.clearPopToDetails();
if (!mounted) return;
if (Navigator.canPop(context)) {
Expand All @@ -325,6 +330,7 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {

final failure = state.failure;
if (failure != null && state.currentStep != null) {
_closeExitDialogIfOpen();
await showAppFeedbackDialog(
context,
title: AppStrings.feedbackErrorTitle,
Expand Down Expand Up @@ -443,22 +449,39 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
}

Future<void> _showExitDialog(BuildContext context) {
if (_isExitDialogOpen) return Future.value();

_isExitDialogOpen = true;
final cubit = context.read<WorkoutExecutionCubit>();
return showAppActionDialog(
context,
title: AppStrings.workoutExecutionExitTitle,
description: AppStrings.workoutExecutionExitDescription,
primaryAction: MainButton(
onPressed: () {
context.pop();
context.read<WorkoutExecutionCubit>().completeWorkout();
},
child: const Text(AppStrings.workoutExecutionExitPrimary),
primaryAction: BlocProvider.value(
value: cubit,
child: BlocBuilder<WorkoutExecutionCubit, WorkoutExecutionState>(
builder: (context, state) {
return MainButton(
state: state.isAbandoning ? ButtonState.loading : ButtonState.enabled,
onPressed: context.read<WorkoutExecutionCubit>().abandonWorkout,
child: const Text(AppStrings.workoutExecutionExitPrimary),
);
},
),
),
secondaryAction: SecondaryButton(
onPressed: () => context.pop(),
child: const Text(AppStrings.workoutExecutionExitSecondary),
secondaryAction: BlocProvider.value(
value: cubit,
child: BlocBuilder<WorkoutExecutionCubit, WorkoutExecutionState>(
builder: (context, state) {
return SecondaryButton(
state: state.isAbandoning ? ButtonState.disabled : ButtonState.enabled,
onPressed: _closeExitDialogIfOpen,
child: const Text(AppStrings.workoutExecutionExitSecondary),
);
},
),
),
);
).whenComplete(() => _isExitDialogOpen = false);
}

Future<void> _showCompletedDialog(BuildContext context) {
Expand All @@ -475,6 +498,29 @@ class _WorkoutExecutionPageState extends State<WorkoutExecutionPage> {
),
);
}

void _closeExitDialogIfOpen() {
if (!_isExitDialogOpen || !mounted) return;

final navigator = Navigator.of(context, rootNavigator: true);
if (!navigator.canPop()) {
_isExitDialogOpen = false;
return;
}

Route<dynamic>? topRoute;
navigator.popUntil((route) {
topRoute = route;
return true;
});
if (topRoute is! PopupRoute<dynamic>) {
_isExitDialogOpen = false;
return;
}

navigator.pop();
_isExitDialogOpen = false;
}
}

String _buildAdjustmentMessage(WorkoutLoadAdjustment adjustment) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkoutsRequestFailure>());

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<UnknownWorkoutsFailure>());
expect(result.failure!.parentException, exception);

verify(apiClient.abandonWorkout(userWorkoutId)).called(1);
verify(logger.e(any, exception, any)).called(1);
verifyNoMoreInteractions(apiClient);
});
});
});
}
Loading
Loading