From 558d7aeb961dc1691828380911521712cd84de60 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:05:09 +0700 Subject: [PATCH 01/13] feat(tests): add auth test attempt api contract and change existed dtos naming --- lib/core/network/api_paths.dart | 6 ++++ .../dto/complete_guest_test_request_dto.dart | 16 --------- .../data/dto/complete_test_request_dto.dart | 16 +++++++++ .../save_guest_test_result_request_dto.dart | 27 --------------- .../save_guest_test_result_response_dto.dart | 19 ----------- ...to.dart => save_test_result_data_dto.dart} | 16 ++++----- .../dto/save_test_result_request_dto.dart | 24 ++++++++++++++ .../dto/save_test_result_response_dto.dart | 19 +++++++++++ .../attempt/data/dto/start_test_data_dto.dart | 31 +++++++++++++++++ .../data/dto/start_test_response_dto.dart | 19 +++++++++++ .../tests/data/remote/tests_api_client.dart | 33 +++++++++++++++---- 11 files changed, 149 insertions(+), 77 deletions(-) delete mode 100644 lib/features/tests/attempt/data/dto/complete_guest_test_request_dto.dart create mode 100644 lib/features/tests/attempt/data/dto/complete_test_request_dto.dart delete mode 100644 lib/features/tests/attempt/data/dto/save_guest_test_result_request_dto.dart delete mode 100644 lib/features/tests/attempt/data/dto/save_guest_test_result_response_dto.dart rename lib/features/tests/attempt/data/dto/{save_guest_test_result_data_dto.dart => save_test_result_data_dto.dart} (57%) create mode 100644 lib/features/tests/attempt/data/dto/save_test_result_request_dto.dart create mode 100644 lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart create mode 100644 lib/features/tests/attempt/data/dto/start_test_data_dto.dart create mode 100644 lib/features/tests/attempt/data/dto/start_test_response_dto.dart diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 9588cedd..06fcd002 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -56,6 +56,12 @@ abstract class ApiPaths { /// The endpoint for all active testings. static const String testings = '${apiPrefix}testings'; + /// The endpoint prefix for authenticated tests. + static const String tests = '${apiPrefix}tests'; + + /// The endpoint prefix for authenticated test attempts. + static const String testAttempts = '${apiPrefix}test-attempts'; + /// The endpoint prefix for guest tests. static const String guestTests = '${apiPrefix}guest/tests'; diff --git a/lib/features/tests/attempt/data/dto/complete_guest_test_request_dto.dart b/lib/features/tests/attempt/data/dto/complete_guest_test_request_dto.dart deleted file mode 100644 index 0efab4ce..00000000 --- a/lib/features/tests/attempt/data/dto/complete_guest_test_request_dto.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'complete_guest_test_request_dto.g.dart'; - -/// DTO request for completing a guest test attempt. -@JsonSerializable() -class CompleteGuestTestRequestDto { - /// Pulse after finishing the testing. - final int pulse; - - /// Creates an instance of [CompleteGuestTestRequestDto]. - CompleteGuestTestRequestDto({required this.pulse}); - - /// Converts this request to JSON. - Map toJson() => _$CompleteGuestTestRequestDtoToJson(this); -} diff --git a/lib/features/tests/attempt/data/dto/complete_test_request_dto.dart b/lib/features/tests/attempt/data/dto/complete_test_request_dto.dart new file mode 100644 index 00000000..6b03b04b --- /dev/null +++ b/lib/features/tests/attempt/data/dto/complete_test_request_dto.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'complete_test_request_dto.g.dart'; + +/// DTO request for completing a test attempt. +@JsonSerializable() +class CompleteTestRequestDto { + /// Pulse after finishing the testing. + final int pulse; + + /// Creates an instance of [CompleteTestRequestDto]. + CompleteTestRequestDto({required this.pulse}); + + /// Converts this request to JSON. + Map toJson() => _$CompleteTestRequestDtoToJson(this); +} diff --git a/lib/features/tests/attempt/data/dto/save_guest_test_result_request_dto.dart b/lib/features/tests/attempt/data/dto/save_guest_test_result_request_dto.dart deleted file mode 100644 index 752a36cb..00000000 --- a/lib/features/tests/attempt/data/dto/save_guest_test_result_request_dto.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'save_guest_test_result_request_dto.g.dart'; - -/// DTO request for saving guest test exercise result. -@JsonSerializable() -class SaveGuestTestResultRequestDto { - /// Exercise identifier inside the testing. - @JsonKey(name: 'testing_exercise_id') - final int testingExerciseId; - - /// Result value from `1` to `4`. - @JsonKey(name: 'result_value') - final int resultValue; - - /// Creates an instance of [SaveGuestTestResultRequestDto]. - SaveGuestTestResultRequestDto({ - required this.testingExerciseId, - required this.resultValue, - }) : assert( - resultValue >= 1 && resultValue <= 4, - 'resultValue must be between 1 and 4', - ); - - /// Converts this request to JSON. - Map toJson() => _$SaveGuestTestResultRequestDtoToJson(this); -} diff --git a/lib/features/tests/attempt/data/dto/save_guest_test_result_response_dto.dart b/lib/features/tests/attempt/data/dto/save_guest_test_result_response_dto.dart deleted file mode 100644 index 248b09a4..00000000 --- a/lib/features/tests/attempt/data/dto/save_guest_test_result_response_dto.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import 'save_guest_test_result_data_dto.dart'; - -part 'save_guest_test_result_response_dto.g.dart'; - -/// DTO envelope for saving guest test exercise result. -@JsonSerializable(createToJson: false) -class SaveGuestTestResultResponseDto { - /// Guest result payload. - final SaveGuestTestResultDataDto data; - - /// Creates an instance of [SaveGuestTestResultResponseDto]. - SaveGuestTestResultResponseDto({required this.data}); - - /// Creates a [SaveGuestTestResultResponseDto] from JSON. - factory SaveGuestTestResultResponseDto.fromJson(Map json) => - _$SaveGuestTestResultResponseDtoFromJson(json); -} diff --git a/lib/features/tests/attempt/data/dto/save_guest_test_result_data_dto.dart b/lib/features/tests/attempt/data/dto/save_test_result_data_dto.dart similarity index 57% rename from lib/features/tests/attempt/data/dto/save_guest_test_result_data_dto.dart rename to lib/features/tests/attempt/data/dto/save_test_result_data_dto.dart index 91161fb0..5cf3f697 100644 --- a/lib/features/tests/attempt/data/dto/save_guest_test_result_data_dto.dart +++ b/lib/features/tests/attempt/data/dto/save_test_result_data_dto.dart @@ -2,11 +2,11 @@ import 'package:json_annotation/json_annotation.dart'; import 'testing_exercise_dto.dart'; -part 'save_guest_test_result_data_dto.g.dart'; +part 'save_test_result_data_dto.g.dart'; -/// DTO payload returned after saving guest exercise result. +/// DTO payload returned after saving exercise result. @JsonSerializable(createToJson: false) -class SaveGuestTestResultDataDto { +class SaveTestResultDataDto { /// Whether the result was saved successfully. final bool saved; @@ -18,14 +18,14 @@ class SaveGuestTestResultDataDto { @JsonKey(name: 'all_exercises_completed') final bool? allExercisesCompleted; - /// Creates an instance of [SaveGuestTestResultDataDto]. - SaveGuestTestResultDataDto({ + /// Creates an instance of [SaveTestResultDataDto]. + SaveTestResultDataDto({ required this.saved, required this.nextExercise, required this.allExercisesCompleted, }); - /// Creates a [SaveGuestTestResultDataDto] from JSON. - factory SaveGuestTestResultDataDto.fromJson(Map json) => - _$SaveGuestTestResultDataDtoFromJson(json); + /// Creates a [SaveTestResultDataDto] from JSON. + factory SaveTestResultDataDto.fromJson(Map json) => + _$SaveTestResultDataDtoFromJson(json); } diff --git a/lib/features/tests/attempt/data/dto/save_test_result_request_dto.dart b/lib/features/tests/attempt/data/dto/save_test_result_request_dto.dart new file mode 100644 index 00000000..4bf9000d --- /dev/null +++ b/lib/features/tests/attempt/data/dto/save_test_result_request_dto.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'save_test_result_request_dto.g.dart'; + +/// DTO request for saving test exercise result. +@JsonSerializable() +class SaveTestResultRequestDto { + /// Exercise identifier inside the testing. + @JsonKey(name: 'testing_exercise_id') + final int testingExerciseId; + + /// Result value from `1` to `4`. + @JsonKey(name: 'result_value') + final int resultValue; + + /// Creates an instance of [SaveTestResultRequestDto]. + SaveTestResultRequestDto({ + required this.testingExerciseId, + required this.resultValue, + }); + + /// Converts this request to JSON. + Map toJson() => _$SaveTestResultRequestDtoToJson(this); +} diff --git a/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart b/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart new file mode 100644 index 00000000..04c4c38c --- /dev/null +++ b/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'save_test_result_data_dto.dart'; + +part 'save_test_result_response_dto.g.dart'; + +/// DTO envelope for saving test exercise result. +@JsonSerializable(createToJson: false) +class SaveTestResultResponseDto { + /// Guest result payload. + final SaveTestResultDataDto data; + + /// Creates an instance of [SaveTestResultResponseDto]. + SaveTestResultResponseDto({required this.data}); + + /// Creates a [SaveTestResultResponseDto] from JSON. + factory SaveTestResultResponseDto.fromJson(Map json) => + _$SaveTestResultResponseDtoFromJson(json); +} diff --git a/lib/features/tests/attempt/data/dto/start_test_data_dto.dart b/lib/features/tests/attempt/data/dto/start_test_data_dto.dart new file mode 100644 index 00000000..911df8af --- /dev/null +++ b/lib/features/tests/attempt/data/dto/start_test_data_dto.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'test_attempt_testing_dto.dart'; +import 'testing_exercise_dto.dart'; + +part 'start_test_data_dto.g.dart'; + +/// DTO payload returned when an authenticated test attempt is started. +@JsonSerializable(createToJson: false) +class StartTestDataDto { + /// Authenticated attempt identifier. + @JsonKey(name: 'attempt_id') + final int attemptId; + + /// Testing summary. + final TestAttemptTestingDto testing; + + /// Current exercise returned by the backend. + @JsonKey(name: 'current_exercise') + final TestingExerciseDto currentExercise; + + /// Creates an instance of [StartTestDataDto]. + StartTestDataDto({ + required this.attemptId, + required this.testing, + required this.currentExercise, + }); + + /// Creates a [StartTestDataDto] from JSON. + factory StartTestDataDto.fromJson(Map json) => _$StartTestDataDtoFromJson(json); +} diff --git a/lib/features/tests/attempt/data/dto/start_test_response_dto.dart b/lib/features/tests/attempt/data/dto/start_test_response_dto.dart new file mode 100644 index 00000000..fd3efdbc --- /dev/null +++ b/lib/features/tests/attempt/data/dto/start_test_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'start_test_data_dto.dart'; + +part 'start_test_response_dto.g.dart'; + +/// DTO envelope for authenticated test start response. +@JsonSerializable(createToJson: false) +class StartTestResponseDto { + /// Authenticated test attempt payload. + final StartTestDataDto data; + + /// Creates an instance of [StartTestResponseDto]. + StartTestResponseDto({required this.data}); + + /// Creates a [StartTestResponseDto] from JSON. + factory StartTestResponseDto.fromJson(Map json) => + _$StartTestResponseDtoFromJson(json); +} diff --git a/lib/features/tests/data/remote/tests_api_client.dart b/lib/features/tests/data/remote/tests_api_client.dart index 9fa4c298..62d2a3c3 100644 --- a/lib/features/tests/data/remote/tests_api_client.dart +++ b/lib/features/tests/data/remote/tests_api_client.dart @@ -2,15 +2,16 @@ import 'package:dio/dio.dart'; import 'package:retrofit/retrofit.dart'; import '../../../../core/network/api_paths.dart'; -import '../../attempt/data/dto/complete_guest_test_request_dto.dart'; -import '../../attempt/data/dto/save_guest_test_result_request_dto.dart'; -import '../../attempt/data/dto/save_guest_test_result_response_dto.dart'; +import '../../attempt/data/dto/complete_test_request_dto.dart'; +import '../../attempt/data/dto/save_test_result_request_dto.dart'; +import '../../attempt/data/dto/save_test_result_response_dto.dart'; import '../../attempt/data/dto/start_guest_test_response_dto.dart'; +import '../../attempt/data/dto/start_test_response_dto.dart'; import '../../catalog/data/dto/testings_response_dto.dart'; part 'tests_api_client.g.dart'; -/// Retrofit API client for tests catalog and guest attempt requests. +/// Retrofit API client for tests catalog and test attempt requests. @RestApi() abstract class TestsApiClient { /// Creates an instance of [TestsApiClient]. @@ -24,17 +25,35 @@ abstract class TestsApiClient { @POST('${ApiPaths.guestTests}/{testing}/start') Future startGuestTest(@Path('testing') int testingId); + /// Starts an authenticated test attempt and returns the first exercise. + @POST('${ApiPaths.tests}/{testing}/start') + Future startTest(@Path('testing') int testingId); + /// Stores the result for the current guest test exercise. @POST('${ApiPaths.guestTestAttempts}/{attempt}/result') - Future saveGuestTestResult( + Future saveGuestTestResult( + @Path('attempt') String attemptId, + @Body() SaveTestResultRequestDto request, + ); + + /// Stores the result for the current authenticated test exercise. + @POST('${ApiPaths.testAttempts}/{attempt}/result') + Future saveTestResult( @Path('attempt') String attemptId, - @Body() SaveGuestTestResultRequestDto request, + @Body() SaveTestResultRequestDto request, ); /// Completes a guest test attempt with pulse value. @POST('${ApiPaths.guestTestAttempts}/{attempt}/complete') Future completeGuestTest( @Path('attempt') String attemptId, - @Body() CompleteGuestTestRequestDto request, + @Body() CompleteTestRequestDto request, + ); + + /// Completes an authenticated test attempt with pulse value. + @POST('${ApiPaths.testAttempts}/{attempt}/complete') + Future completeTest( + @Path('attempt') String attemptId, + @Body() CompleteTestRequestDto request, ); } From 0cfdd7081a0a8310d7c5e3618828fd162933add6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:12:00 +0700 Subject: [PATCH 02/13] feat(tests): add new auth tests repository interface and separate it from guest's --- lib/core/di/di.dart | 2 +- .../fitness_start_test_attempt_page_builder.dart | 2 +- .../guest_test_attempt_repository_impl.dart | 14 ++++++-------- .../repositories/test_attempt_repository.dart | 6 ++++++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index aa68bccd..6892a5ca 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -139,7 +139,7 @@ Future setupDI() async { di(), ), ); - di.registerLazySingleton( + di.registerLazySingleton( () => GuestTestAttemptRepositoryImpl( di(), di(), diff --git a/lib/features/fitness_start/presentation/pages/fitness_start_test_attempt_page_builder.dart b/lib/features/fitness_start/presentation/pages/fitness_start_test_attempt_page_builder.dart index 19147e44..7224462f 100644 --- a/lib/features/fitness_start/presentation/pages/fitness_start_test_attempt_page_builder.dart +++ b/lib/features/fitness_start/presentation/pages/fitness_start_test_attempt_page_builder.dart @@ -20,7 +20,7 @@ class FitnessStartTestAttemptPageBuilder extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => TestAttemptCubit(di())..startTest(testingId), + create: (_) => TestAttemptCubit(di())..startTest(testingId), child: FitnessStartTestAttemptPage(testingId: testingId), ); } diff --git a/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart b/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart index c639bee1..8737f20a 100644 --- a/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart +++ b/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart @@ -8,14 +8,12 @@ import '../../../data/remote/tests_api_client.dart'; import '../../domain/entities/test_attempt_result.dart'; import '../../domain/entities/test_attempt_start.dart'; import '../../domain/repositories/test_attempt_repository.dart'; -import '../../../catalog/data/mappers/tests_failure_mapper.dart'; -import '../dto/complete_guest_test_request_dto.dart'; -import '../dto/save_guest_test_result_data_dto.dart'; -import '../dto/save_guest_test_result_request_dto.dart'; +import '../dto/complete_test_request_dto.dart'; +import '../dto/save_test_result_request_dto.dart'; import '../mappers/test_attempt_mapper.dart'; -/// Guest implementation of [TestAttemptRepository]. -final class GuestTestAttemptRepositoryImpl implements TestAttemptRepository { +/// Guest implementation of [GuestTestAttemptRepository]. +final class GuestTestAttemptRepositoryImpl implements GuestTestAttemptRepository { /// Logger for tracking guest test attempt operations. final AppLogger _logger; @@ -46,7 +44,7 @@ final class GuestTestAttemptRepositoryImpl implements TestAttemptRepository { required int resultValue, }) async { try { - final request = SaveGuestTestResultRequestDto( + final request = SaveTestResultRequestDto( testingExerciseId: testingExerciseId, resultValue: resultValue, ); @@ -73,7 +71,7 @@ final class GuestTestAttemptRepositoryImpl implements TestAttemptRepository { required int pulse, }) async { try { - final request = CompleteGuestTestRequestDto(pulse: pulse); + final request = CompleteTestRequestDto(pulse: pulse); await _apiClient.completeGuestTest(attemptId, request); return const Result.success(null); } on DioException catch (e) { diff --git a/lib/features/tests/attempt/domain/repositories/test_attempt_repository.dart b/lib/features/tests/attempt/domain/repositories/test_attempt_repository.dart index a453e008..8a888eaf 100644 --- a/lib/features/tests/attempt/domain/repositories/test_attempt_repository.dart +++ b/lib/features/tests/attempt/domain/repositories/test_attempt_repository.dart @@ -21,3 +21,9 @@ abstract interface class TestAttemptRepository { required int pulse, }); } + +/// Repository contract for guest test attempts. +abstract interface class GuestTestAttemptRepository implements TestAttemptRepository {} + +/// Repository contract for authenticated test attempts. +abstract interface class AuthenticatedTestAttemptRepository implements TestAttemptRepository {} From 1647b061d3f593f991cd89ac4a8eefa16f3ee8d2 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:13:56 +0700 Subject: [PATCH 03/13] feat(data): add StartTestMapper --- .../data/mappers/test_attempt_mapper.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/features/tests/attempt/data/mappers/test_attempt_mapper.dart b/lib/features/tests/attempt/data/mappers/test_attempt_mapper.dart index 6c86f521..f62cea1b 100644 --- a/lib/features/tests/attempt/data/mappers/test_attempt_mapper.dart +++ b/lib/features/tests/attempt/data/mappers/test_attempt_mapper.dart @@ -3,8 +3,9 @@ import '../../domain/entities/test_attempt_result.dart'; import '../../domain/entities/test_attempt_start.dart'; import '../../domain/entities/test_attempt_testing.dart'; import '../../domain/entities/testing_exercise.dart'; -import '../dto/save_guest_test_result_data_dto.dart'; +import '../dto/save_test_result_data_dto.dart'; import '../dto/start_guest_test_data_dto.dart'; +import '../dto/start_test_data_dto.dart'; import '../dto/test_attempt_testing_dto.dart'; import '../dto/testing_exercise_dto.dart'; @@ -38,8 +39,18 @@ extension StartGuestTestMapper on StartGuestTestDataDto { ); } -/// Extension that maps [SaveGuestTestResultDataDto] to [TestAttemptResult]. -extension SaveGuestTestResultMapper on SaveGuestTestResultDataDto { +/// Extension that maps [StartTestDataDto] to [TestAttemptStart]. +extension StartTestMapper on StartTestDataDto { + /// Converts DTO to a domain entity. + TestAttemptStart toEntity() => TestAttemptStart( + attemptId: attemptId.toString(), + testing: testing.toEntity(), + currentExercise: currentExercise.toEntity(), + ); +} + +/// Extension that maps save-result DTOs to [TestAttemptResult]. +extension SaveGuestTestResultMapper on SaveTestResultDataDto { /// Converts DTO to a domain entity. TestAttemptResult toEntity() => TestAttemptResult( nextExercise: nextExercise?.toEntity(), From 0ac677ec6c0f4138d734779c1e6ed09b4e6497d6 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:15:11 +0700 Subject: [PATCH 04/13] refactor(data): extract test attempt result payload to separate file --- .../guest_test_attempt_repository_impl.dart | 17 +++-------------- .../test_attempt_result_payload_validator.dart | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 lib/features/tests/attempt/data/repositories/test_attempt_result_payload_validator.dart diff --git a/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart b/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart index 8737f20a..e654ddac 100644 --- a/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart +++ b/lib/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart @@ -4,6 +4,7 @@ import '../../../../../core/failures/feature/tests/tests_failure.dart'; import '../../../../../core/network/mappers/dio_exception_mapper.dart'; import '../../../../../core/result/result.dart'; import '../../../../../core/utils/logger/app_logger.dart'; +import '../../../catalog/data/mappers/tests_failure_mapper.dart'; import '../../../data/remote/tests_api_client.dart'; import '../../domain/entities/test_attempt_result.dart'; import '../../domain/entities/test_attempt_start.dart'; @@ -11,6 +12,7 @@ import '../../domain/repositories/test_attempt_repository.dart'; import '../dto/complete_test_request_dto.dart'; import '../dto/save_test_result_request_dto.dart'; import '../mappers/test_attempt_mapper.dart'; +import 'test_attempt_result_payload_validator.dart'; /// Guest implementation of [GuestTestAttemptRepository]. final class GuestTestAttemptRepositoryImpl implements GuestTestAttemptRepository { @@ -50,7 +52,7 @@ final class GuestTestAttemptRepositoryImpl implements GuestTestAttemptRepository ); final response = await _apiClient.saveGuestTestResult(attemptId, request); final payload = response.data; - if (!_isValidGuestResultPayload(payload)) { + if (!isValidTestAttemptResultPayload(payload)) { final exception = StateError('Malformed guest test result payload.'); _logger.e('SaveGuestTestResult returned malformed payload', exception); return Result.failure(UnknownTestsFailure(parentException: exception)); @@ -83,16 +85,3 @@ final class GuestTestAttemptRepositoryImpl implements GuestTestAttemptRepository } } } - -bool _isValidGuestResultPayload(SaveGuestTestResultDataDto payload) { - if (!payload.saved) return false; - - final hasNextExercise = payload.nextExercise != null; - final isCompleted = payload.allExercisesCompleted == true; - - if (hasNextExercise) { - return !isCompleted; - } - - return isCompleted; -} diff --git a/lib/features/tests/attempt/data/repositories/test_attempt_result_payload_validator.dart b/lib/features/tests/attempt/data/repositories/test_attempt_result_payload_validator.dart new file mode 100644 index 00000000..b7c7cbee --- /dev/null +++ b/lib/features/tests/attempt/data/repositories/test_attempt_result_payload_validator.dart @@ -0,0 +1,15 @@ +import '../dto/save_test_result_data_dto.dart'; + +/// Validates that a test-result payload has either a next exercise or a completion flag. +bool isValidTestAttemptResultPayload(SaveTestResultDataDto payload) { + if (!payload.saved) return false; + + final hasNextExercise = payload.nextExercise != null; + final isCompleted = payload.allExercisesCompleted == true; + + if (hasNextExercise) { + return !isCompleted; + } + + return isCompleted; +} From 86cf38b6c7b26644df9da41f6b02e2ceeddc72bf Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:15:50 +0700 Subject: [PATCH 05/13] feat(data): implement AuthenticatedTestAttemptRepository --- ...nticated_test_attempt_repository_impl.dart | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 lib/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart diff --git a/lib/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart b/lib/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart new file mode 100644 index 00000000..3f3de555 --- /dev/null +++ b/lib/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart @@ -0,0 +1,87 @@ +import 'package:dio/dio.dart'; + +import '../../../../../core/failures/feature/tests/tests_failure.dart'; +import '../../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../../core/result/result.dart'; +import '../../../../../core/utils/logger/app_logger.dart'; +import '../../../catalog/data/mappers/tests_failure_mapper.dart'; +import '../../../data/remote/tests_api_client.dart'; +import '../../domain/entities/test_attempt_result.dart'; +import '../../domain/entities/test_attempt_start.dart'; +import '../../domain/repositories/test_attempt_repository.dart'; +import '../dto/complete_test_request_dto.dart'; +import '../dto/save_test_result_request_dto.dart'; +import '../mappers/test_attempt_mapper.dart'; +import 'test_attempt_result_payload_validator.dart'; + +/// Authenticated implementation of [AuthenticatedTestAttemptRepository]. +final class AuthenticatedTestAttemptRepositoryImpl implements AuthenticatedTestAttemptRepository { + /// Logger for tracking authenticated test attempt operations. + final AppLogger _logger; + + /// API client for tests catalog and attempts. + final TestsApiClient _apiClient; + + /// Creates an instance of [AuthenticatedTestAttemptRepositoryImpl]. + AuthenticatedTestAttemptRepositoryImpl(this._logger, this._apiClient); + + @override + Future> startTest(int testingId) async { + try { + final response = await _apiClient.startTest(testingId); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toTestsFailure()); + } catch (e, s) { + _logger.e('StartTest failed with unexpected error', e, s); + return Result.failure(UnknownTestsFailure(parentException: e, stackTrace: s)); + } + } + + @override + Future> saveResult({ + required String attemptId, + required int testingExerciseId, + required int resultValue, + }) async { + try { + final request = SaveTestResultRequestDto( + testingExerciseId: testingExerciseId, + resultValue: resultValue, + ); + final response = await _apiClient.saveTestResult(attemptId, request); + final payload = response.data; + if (!isValidTestAttemptResultPayload(payload)) { + final exception = StateError('Malformed authenticated test result payload.'); + _logger.e('SaveTestResult returned malformed payload', exception); + return Result.failure(UnknownTestsFailure(parentException: exception)); + } + return Result.success(payload.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toTestsFailure()); + } catch (e, s) { + _logger.e('SaveTestResult failed with unexpected error', e, s); + return Result.failure(UnknownTestsFailure(parentException: e, stackTrace: s)); + } + } + + @override + Future> completeTest({ + required String attemptId, + required int pulse, + }) async { + try { + final request = CompleteTestRequestDto(pulse: pulse); + await _apiClient.completeTest(attemptId, request); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toTestsFailure()); + } catch (e, s) { + _logger.e('CompleteTest failed with unexpected error', e, s); + return Result.failure(UnknownTestsFailure(parentException: e, stackTrace: s)); + } + } +} From 25283fe23c428253a8c539f04da52285865442aa Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:16:56 +0700 Subject: [PATCH 06/13] test(tests): add unit tests for AuthenticatedTestAttemptRepositoryImpl + fixtures and edit guest ones --- ...ted_test_attempt_repository_impl_test.dart | 229 ++++++++++++++++++ ...est_test_attempt_repository_impl_test.dart | 8 +- .../support/test_attempt_dto_fixtures.dart | 25 +- 3 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart diff --git a/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart new file mode 100644 index 00000000..94fdfa18 --- /dev/null +++ b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart @@ -0,0 +1,229 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/tests/tests_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/complete_test_request_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/save_test_result_request_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart'; +import 'package:moveup_flutter/features/tests/attempt/domain/repositories/test_attempt_repository.dart'; +import 'package:moveup_flutter/features/tests/data/remote/tests_api_client.dart'; + +import '../../../catalog/support/testings_dto_fixtures.dart'; +import '../../support/test_attempt_dto_fixtures.dart'; +import 'authenticated_test_attempt_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockTestsApiClient apiClient; + late AuthenticatedTestAttemptRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockTestsApiClient(); + repository = AuthenticatedTestAttemptRepositoryImpl(logger, apiClient); + }); + + group('AuthenticatedTestAttemptRepositoryImpl', () { + group('AuthenticatedTestAttemptRepositoryImpl.startTest', () { + test('returns success(start) when api succeeds', () async { + final responseDto = createStartTestResponseDto(); + when(apiClient.startTest(8)).thenAnswer((_) async => responseDto); + + final result = await repository.startTest(8); + + expect(result.isSuccess, isTrue); + expect(result.success!.attemptId, '11'); + expect(result.success!.testing, createTestAttemptStart().testing); + expect(result.success!.currentExercise, createTestAttemptStart().currentExercise); + + verify(apiClient.startTest(8)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns TestsRequestFailure when api returns server error', () async { + final exception = createTestsDioBadResponseException( + path: '/tests/8/start', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.startTest(8)).thenThrow(exception); + + final result = await repository.startTest(8); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.startTest(8)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownTestsFailure when unexpected exception occurs', () async { + final exception = Exception('unexpected_error'); + when(apiClient.startTest(8)).thenThrow(exception); + + final result = await repository.startTest(8); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.startTest(8)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('AuthenticatedTestAttemptRepositoryImpl.saveResult', () { + test('returns success(result) when api succeeds with next exercise', () async { + final responseDto = createSaveGuestTestResultResponseDto( + nextExercise: createTestingExerciseDto(id: 17, orderNumber: 2), + allExercisesCompleted: false, + ); + final expectedResult = createTestAttemptNextExerciseResult(); + when(apiClient.saveTestResult(any, any)).thenAnswer((_) async => responseDto); + + final result = await repository.saveResult( + attemptId: '11', + testingExerciseId: 16, + resultValue: 2, + ); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedResult); + + final captured = verify(apiClient.saveTestResult(captureAny, captureAny)).captured; + expect(captured.first, '11'); + expect((captured.last as SaveTestResultRequestDto).toJson(), { + 'testing_exercise_id': 16, + 'result_value': 2, + }); + verifyNoMoreInteractions(apiClient); + }); + + test('returns success(result) when all exercises are completed', () async { + final responseDto = createSaveGuestTestResultResponseDto(allExercisesCompleted: true); + final expectedResult = createTestAttemptAwaitingPulseResult(); + when(apiClient.saveTestResult(any, any)).thenAnswer((_) async => responseDto); + + final result = await repository.saveResult( + attemptId: '11', + testingExerciseId: 16, + resultValue: 4, + ); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedResult); + + verify(apiClient.saveTestResult(any, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownTestsFailure when api returns malformed payload', () async { + final responseDto = createSaveGuestTestResultResponseDto(); + when(apiClient.saveTestResult(any, any)).thenAnswer((_) async => responseDto); + + final result = await repository.saveResult( + attemptId: '11', + testingExerciseId: 16, + resultValue: 4, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, isA()); + + verify(apiClient.saveTestResult(any, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns TestsRequestFailure when api throws DioException', () async { + final exception = createTestsDioBadResponseException( + path: '/test-attempts/11/result', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.saveTestResult(any, any)).thenThrow(exception); + + final result = await repository.saveResult( + attemptId: '11', + testingExerciseId: 16, + resultValue: 2, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveTestResult(any, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('AuthenticatedTestAttemptRepositoryImpl.completeTest', () { + test('returns success(void) when api succeeds', () async { + when(apiClient.completeTest(any, any)).thenAnswer((_) async {}); + + final result = await repository.completeTest( + attemptId: '11', + pulse: 151, + ); + + expect(result.isSuccess, isTrue); + + final captured = verify(apiClient.completeTest(captureAny, captureAny)).captured; + expect(captured.first, '11'); + expect((captured.last as CompleteTestRequestDto).toJson(), { + 'pulse': 151, + }); + verifyNoMoreInteractions(apiClient); + }); + + test('returns TestsValidationFailure when api returns 422', () async { + final exception = createTestsDioBadResponseException( + path: '/test-attempts/11/complete', + statusCode: 422, + code: 'validation_failed', + errors: const { + 'pulse': ['Пульс должен быть от 30 до 220'], + }, + ); + when(apiClient.completeTest(any, any)).thenThrow(exception); + + final result = await repository.completeTest( + attemptId: '11', + pulse: 151, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.completeTest(any, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownTestsFailure when unexpected exception occurs', () async { + final exception = Exception('unexpected_error'); + when(apiClient.completeTest(any, any)).thenThrow(exception); + + final result = await repository.completeTest( + attemptId: '11', + pulse: 151, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.completeTest(any, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + }); +} diff --git a/test/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl_test.dart b/test/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl_test.dart index bf90867a..d7a073fe 100644 --- a/test/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl_test.dart +++ b/test/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl_test.dart @@ -3,8 +3,8 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:moveup_flutter/core/failures/feature/tests/tests_failure.dart'; import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; -import 'package:moveup_flutter/features/tests/attempt/data/dto/complete_guest_test_request_dto.dart'; -import 'package:moveup_flutter/features/tests/attempt/data/dto/save_guest_test_result_request_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/complete_test_request_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/save_test_result_request_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart'; import 'package:moveup_flutter/features/tests/attempt/domain/repositories/test_attempt_repository.dart'; import 'package:moveup_flutter/features/tests/data/remote/tests_api_client.dart'; @@ -99,7 +99,7 @@ void main() { apiClient.saveGuestTestResult(captureAny, captureAny), ).captured; expect(captured.first, 'guest_attempt_1'); - expect((captured.last as SaveGuestTestResultRequestDto).toJson(), { + expect((captured.last as SaveTestResultRequestDto).toJson(), { 'testing_exercise_id': 16, 'result_value': 2, }); @@ -178,7 +178,7 @@ void main() { final captured = verify(apiClient.completeGuestTest(captureAny, captureAny)).captured; expect(captured.first, 'guest_attempt_1'); - expect((captured.last as CompleteGuestTestRequestDto).toJson(), { + expect((captured.last as CompleteTestRequestDto).toJson(), { 'pulse': 151, }); verifyNoMoreInteractions(apiClient); diff --git a/test/features/tests/attempt/support/test_attempt_dto_fixtures.dart b/test/features/tests/attempt/support/test_attempt_dto_fixtures.dart index 4b573f27..a445bc68 100644 --- a/test/features/tests/attempt/support/test_attempt_dto_fixtures.dart +++ b/test/features/tests/attempt/support/test_attempt_dto_fixtures.dart @@ -1,7 +1,9 @@ -import 'package:moveup_flutter/features/tests/attempt/data/dto/save_guest_test_result_data_dto.dart'; -import 'package:moveup_flutter/features/tests/attempt/data/dto/save_guest_test_result_response_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/save_test_result_data_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/save_test_result_response_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/dto/start_guest_test_data_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/dto/start_guest_test_response_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/start_test_data_dto.dart'; +import 'package:moveup_flutter/features/tests/attempt/data/dto/start_test_response_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/dto/test_attempt_testing_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/dto/testing_exercise_dto.dart'; import 'package:moveup_flutter/features/tests/attempt/data/mappers/test_attempt_mapper.dart'; @@ -44,18 +46,31 @@ StartGuestTestResponseDto createStartGuestTestResponseDto({ ); /// Test fixture for save-result response DTO. -SaveGuestTestResultResponseDto createSaveGuestTestResultResponseDto({ +SaveTestResultResponseDto createSaveGuestTestResultResponseDto({ bool saved = true, TestingExerciseDto? nextExercise, bool? allExercisesCompleted, -}) => SaveGuestTestResultResponseDto( - data: SaveGuestTestResultDataDto( +}) => SaveTestResultResponseDto( + data: SaveTestResultDataDto( saved: saved, nextExercise: nextExercise, allExercisesCompleted: allExercisesCompleted, ), ); +/// Test fixture for authenticated start response DTO. +StartTestResponseDto createStartTestResponseDto({ + int attemptId = 11, + TestAttemptTestingDto? testing, + TestingExerciseDto? currentExercise, +}) => StartTestResponseDto( + data: StartTestDataDto( + attemptId: attemptId, + testing: testing ?? createTestAttemptTestingDto(), + currentExercise: currentExercise ?? createTestingExerciseDto(), + ), +); + /// Test fixture for started attempt entity. TestAttemptStart createTestAttemptStart() => createStartGuestTestResponseDto().data.toEntity(); From afea041c3332f061e1ba27de58a74f5cc495b0b1 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 11:18:00 +0700 Subject: [PATCH 07/13] docs:(test-attempt-ui): update summary for TestAttemptCubit --- .../tests/attempt/presentation/cubits/test_attempt_cubit.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/tests/attempt/presentation/cubits/test_attempt_cubit.dart b/lib/features/tests/attempt/presentation/cubits/test_attempt_cubit.dart index 2ceca256..18166596 100644 --- a/lib/features/tests/attempt/presentation/cubits/test_attempt_cubit.dart +++ b/lib/features/tests/attempt/presentation/cubits/test_attempt_cubit.dart @@ -10,7 +10,7 @@ import '../../domain/repositories/test_attempt_repository.dart'; part 'test_attempt_cubit.freezed.dart'; part 'test_attempt_state.dart'; -/// Cubit that manages guest test attempt start, progress, and completion. +/// Cubit that manages test attempt start, progress, and completion. final class TestAttemptCubit extends Cubit { final TestAttemptRepository _repository; From c4c4019c5f36f85a60a34e88428fa1f5b21fef85 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 13:30:34 +0700 Subject: [PATCH 08/13] feat(tests): add auth tests attempt page and route --- lib/core/di/di.dart | 7 + lib/core/router/router.dart | 17 + lib/core/router/router_paths.dart | 6 + .../pages/tests_attempt_page.dart | 360 ++++++++++++++++++ .../pages/tests_attempt_page_builder.dart | 28 ++ .../pages/tests_catalog_page.dart | 2 +- 6 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 lib/features/tests/attempt/presentation/pages/tests_attempt_page.dart create mode 100644 lib/features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 6892a5ca..98fdff9c 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -17,6 +17,7 @@ import '../../features/fitness_start/data/remote/fitness_start_api_client.dart'; import '../../features/fitness_start/data/repositories/fitness_start_repository_impl.dart'; import '../../features/fitness_start/domain/repositories/fitness_start_repository.dart'; import '../../features/offline/presentation/cubit/network_cubit.dart'; +import '../../features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart'; import '../../features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart'; import '../../features/tests/attempt/domain/repositories/test_attempt_repository.dart'; import '../../features/tests/catalog/data/repositories/tests_catalog_repository_impl.dart'; @@ -145,6 +146,12 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton( + () => AuthenticatedTestAttemptRepositoryImpl( + di(), + di(), + ), + ); // Workouts di.registerLazySingleton(() => WorkoutsApiClient(di())); diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index 713e3279..d7d58194 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -23,6 +23,7 @@ import '../../features/offline/presentation/cubit/network_cubit.dart'; import '../../features/offline/presentation/pages/offline_page.dart'; import '../../features/root/presentation/pages/root_screen.dart'; import '../../features/splash/presentation/pages/splash_page.dart'; +import '../../features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart'; import '../../features/tests/catalog/presentation/pages/tests_catalog_page_builder.dart'; import '../../features/workouts/details/presentation/pages/workout_details_page_builder.dart'; import '../../features/workouts/execution/domain/entities/workout_execution_entry_mode.dart'; @@ -178,6 +179,22 @@ final router = GoRouter( GoRoute( path: AppRoutePaths.testsPath, builder: (_, _) => const TestsCatalogPageBuilder(), + routes: [ + GoRoute( + path: AppRoutePaths.testsAttemptPath, + parentNavigatorKey: _rootKey, + redirect: (_, state) { + final testingId = int.tryParse(state.pathParameters['testingId'] ?? ''); + if (testingId == null || testingId <= 0) { + return AppRoutePaths.testsPath; + } + return null; + }, + builder: (_, state) => TestsAttemptPageBuilder( + testingId: int.parse(state.pathParameters['testingId']!), + ), + ), + ], ), ], ), diff --git a/lib/core/router/router_paths.dart b/lib/core/router/router_paths.dart index f6f1b0e8..2e2bc767 100644 --- a/lib/core/router/router_paths.dart +++ b/lib/core/router/router_paths.dart @@ -12,6 +12,12 @@ abstract class AppRoutePaths { /// Route path for the tests root tab. static const testsPath = '/tests'; + /// Route path pattern for a concrete authenticated test attempt. + static const testsAttemptPath = 'attempt/:testingId'; + + /// Builds the concrete route path for an authenticated test attempt by [testingId]. + static String testsAttemptDetailsPath(int testingId) => '$testsPath/attempt/$testingId'; + /// Route path for the profile root tab. static const profilePath = '/profile'; diff --git a/lib/features/tests/attempt/presentation/pages/tests_attempt_page.dart b/lib/features/tests/attempt/presentation/pages/tests_attempt_page.dart new file mode 100644 index 00000000..5ce4c504 --- /dev/null +++ b/lib/features/tests/attempt/presentation/pages/tests_attempt_page.dart @@ -0,0 +1,360 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../../core/constants/app_assets.dart'; +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/router/router_paths.dart'; +import '../../../../../uikit/buttons/app_back_button.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/option_button.dart'; +import '../../../../../uikit/cards/app_card.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.dart'; +import '../../../../../uikit/images/network_image_widget.dart'; +import '../../../../../uikit/images/svg_picture_widget.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../cubits/test_attempt_cubit.dart'; + +/// Authenticated page for a single test attempt. +class TestsAttemptPage extends StatefulWidget { + /// Testing identifier used for start retry. + final int testingId; + + /// Creates an instance of [TestsAttemptPage]. + const TestsAttemptPage({ + required this.testingId, + super.key, + }); + + @override + State createState() => _TestsAttemptPageState(); +} + +class _TestsAttemptPageState extends State { + final _pulseFormKey = GlobalKey(); + final _pulseController = TextEditingController(); + final _resultLabels = const [ + AppStrings.testsAttemptResultVeryPoor, + AppStrings.testsAttemptResultPoor, + AppStrings.testsAttemptResultNormal, + AppStrings.testsAttemptResultGood, + ]; + + TestAttemptCubit get _cubit => context.read(); + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + String? _validatePulse(String? value) { + final trimmedValue = value?.trim() ?? ''; + if (trimmedValue.isEmpty) return AppStrings.testsAttemptPulseRequired; + + final pulse = int.tryParse(trimmedValue); + if (pulse == null) return AppStrings.testsAttemptPulseInvalid; + if (pulse < 30 || pulse > 220) return AppStrings.testsAttemptPulseRange; + + return null; + } + + void _returnToCatalog() { + final router = GoRouter.of(context); + if (router.canPop()) { + router.pop(); + return; + } + router.go(AppRoutePaths.testsPath); + } + + Future _submitPulse() async { + FocusScope.of(context).unfocus(); + final form = _pulseFormKey.currentState; + if (form == null || !form.validate()) return; + + await _cubit.submitPulse(int.parse(_pulseController.text.trim())); + } + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + return BlocConsumer( + listenWhen: (previous, current) => + previous.failure != current.failure || !previous.isCompleted && current.isCompleted, + listener: (context, state) { + final failure = state.failure; + if (failure != null && state.testing != null) { + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ); + _cubit.clearFailure(); + } + if (state.isCompleted) { + unawaited(Future.delayed(Duration.zero, _returnToCatalog)); + } + }, + builder: (context, state) { + final textTheme = AppTextTheme.of(context); + return Scaffold( + appBar: AppBar( + leading: AppBackButton(onPressed: _returnToCatalog), + title: Text(AppStrings.testsAttemptTitle, style: textTheme.appBarTitle), + ), + body: Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: -140, + top: 10, + child: IgnorePointer( + child: ExcludeSemantics( + child: Transform.rotate( + angle: -200 * (math.pi / 180), + child: Transform.scale( + scaleY: -1, + child: SvgPictureWidget.frame( + AppAssets.imageFigure, + color: colorTheme.primary.withValues(alpha: 0.3), + ), + ), + ), + ), + ), + ), + Positioned( + right: -85, + bottom: -110, + child: IgnorePointer( + child: ExcludeSemantics( + child: SvgPictureWidget.frame( + AppAssets.imageFigure, + color: colorTheme.secondary.withValues(alpha: 0.3), + ), + ), + ), + ), + if (state.testing == null) + _buildStartState(context, state) + else + _buildLoadedState(context, state), + ], + ), + ); + }, + ); + } + + Widget _buildLoadingState() { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ), + ); + } + + Widget _buildStartState(BuildContext context, TestAttemptState state) { + if (state.isStarting) return _buildLoadingState(); + + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.testsStartFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 24), + MainButton( + onPressed: () => _cubit.startTest(widget.testingId), + child: const Text(AppStrings.fitnessStartRetryButton), + ), + ], + ), + ), + ); + } + + Widget _buildLoadedState(BuildContext context, TestAttemptState state) { + if (state.isCompleted) return _buildLoadingState(); + if (state.isAwaitingPulse) return _buildPulseStepLayout(context, state); + if (state.currentExercise == null) return _buildLoadingState(); + + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.testsAttemptDescription, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith( + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 20), + _buildExerciseContent(context, state), + ], + ), + ); + } + + Widget _buildPulseStepLayout(BuildContext context, TestAttemptState state) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 64), + child: SizedBox.expand( + child: Form( + key: _pulseFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.testsAttemptPulseTitle, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith( + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 20), + Expanded( + child: SingleChildScrollView( + child: _buildPulseContent(context, state), + ), + ), + const SizedBox(height: 24), + MainButton( + state: state.isCompleting ? ButtonState.loading : ButtonState.enabled, + onPressed: _submitPulse, + child: const Text(AppStrings.testsAttemptCompleteButton), + ), + ], + ), + ), + ), + ); + } + + Widget _buildExerciseContent(BuildContext context, TestAttemptState state) { + final exercise = state.currentExercise!; + final testing = state.testing!; + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final buttonState = state.isSubmittingResult ? ButtonState.disabled : ButtonState.enabled; + return AppCard( + child: LayoutBuilder( + builder: (context, constraints) { + final itemWidth = (constraints.maxWidth - 8) / 2; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: NetworkImageWidget( + imageUrl: exercise.imageUrl, + height: constraints.maxWidth, + ), + ), + const SizedBox(height: 20), + Text( + testing.title, + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + exercise.description, + textAlign: TextAlign.end, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(color: colorTheme.hint), + ), + const SizedBox(height: 24), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(4, (index) { + final value = index + 1; + final label = _resultLabels[index]; + return SizedBox( + width: itemWidth, + child: OptionButton( + state: buttonState, + onPressed: () => _cubit.submitResult(value), + child: Text(label), + ), + ); + }), + ), + ], + ); + }, + ), + ); + } + + Widget _buildPulseContent(BuildContext context, TestAttemptState state) { + final exercise = state.currentExercise; + if (exercise == null) { + return const SizedBox.shrink(); + } + + return LayoutBuilder( + builder: (context, constraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: NetworkImageWidget( + imageUrl: exercise.imageUrl, + height: constraints.maxWidth, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _pulseController, + enabled: !state.isCompleting, + keyboardType: TextInputType.number, + validator: _validatePulse, + textInputAction: TextInputAction.done, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + hintText: AppStrings.testsAttemptPulseHint, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart b/lib/features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart new file mode 100644 index 00000000..4934f61d --- /dev/null +++ b/lib/features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart @@ -0,0 +1,28 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/di/di.dart'; +import '../../domain/repositories/test_attempt_repository.dart'; +import '../cubits/test_attempt_cubit.dart'; +import 'tests_attempt_page.dart'; + +/// Builder for the authenticated tests attempt page. +class TestsAttemptPageBuilder extends StatelessWidget { + /// Testing identifier to be started on page open. + final int testingId; + + /// Creates an instance of [TestsAttemptPageBuilder]. + const TestsAttemptPageBuilder({ + required this.testingId, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => + TestAttemptCubit(di())..startTest(testingId), + child: TestsAttemptPage(testingId: testingId), + ); + } +} diff --git a/lib/features/tests/catalog/presentation/pages/tests_catalog_page.dart b/lib/features/tests/catalog/presentation/pages/tests_catalog_page.dart index eaded3c1..f4d62898 100644 --- a/lib/features/tests/catalog/presentation/pages/tests_catalog_page.dart +++ b/lib/features/tests/catalog/presentation/pages/tests_catalog_page.dart @@ -296,7 +296,7 @@ class _TestsCatalogPageState extends State { padding: EdgeInsets.only(bottom: index == filteredItems.length - 1 ? 0 : 20), child: TestingCatalogCard( item: item, - onPressed: () => context.push(AppRoutePaths.debugPath), + onPressed: () => context.push(AppRoutePaths.testsAttemptDetailsPath(item.id)), ), ); }, childCount: filteredItems.length), From cf6b1c0388dcb434dd54510669f3e7fa6d47a881 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 13:35:41 +0700 Subject: [PATCH 09/13] docs: update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c332d04..ce70d50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workout details feature for authenticated users, including `GET /api/workout-execution/{userWorkout}`, details repository, Cubit, route, and the details UI based on the provided mockup. - Authenticated tests catalog screen for the `/tests` root tab, reusing the existing tests catalog slice and adding local search plus multi-select category filtering UI before navigating to the debug screen. - Workout execution feature for authenticated users, including execution DTOs/mappers, repository, Cubit, fullscreen route, local rest countdown, dialogs, and the warmup/training UI backed by workout start/progression/complete endpoints. +- Authenticated test attempt flow for `/tests/attempt/:testingId`, including auth API client methods, repository wiring, fullscreen attempt route, and the attempt UI mirrored from the Fitness Start flow. ### Changed @@ -25,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - 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. +- Shared test-attempt transport DTOs were renamed from guest-prefixed names to neutral request/response models because the same payload shapes are now reused by both guest and authenticated flows. +- Test-attempt DI wiring now resolves separate guest and authenticated repository bindings while keeping the shared `TestAttemptCubit` and domain contract unchanged. - `TestingCatalogCard` now skips the extra spacing above category chips when a test has no categories. ## [0.3.1] - 2026-03-25 From fc127d108eb54591e3b2db691b60aa179c29a059 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 13:56:22 +0700 Subject: [PATCH 10/13] docs(tests): update summary for SaveTestResultResponseDto field --- .../tests/attempt/data/dto/save_test_result_response_dto.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart b/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart index 04c4c38c..0ed6f5e9 100644 --- a/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart +++ b/lib/features/tests/attempt/data/dto/save_test_result_response_dto.dart @@ -7,7 +7,7 @@ part 'save_test_result_response_dto.g.dart'; /// DTO envelope for saving test exercise result. @JsonSerializable(createToJson: false) class SaveTestResultResponseDto { - /// Guest result payload. + /// Saved test result payload. final SaveTestResultDataDto data; /// Creates an instance of [SaveTestResultResponseDto]. From 9cb4ba7fcfe03063ae12b82dd17d3d6b252db648 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 13:57:19 +0700 Subject: [PATCH 11/13] test(tests): add missing logger verification for unexpected exception --- .../authenticated_test_attempt_repository_impl_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart index 94fdfa18..4edbd0f2 100644 --- a/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart +++ b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart @@ -222,6 +222,7 @@ void main() { expect(result.failure!.parentException, exception); verify(apiClient.completeTest(any, any)).called(1); + verify(logger.e(any, exception, any)).called(1); verifyNoMoreInteractions(apiClient); }); }); From b1b79e0288869a26d35a3ca105e957fbf227e0d0 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 13:57:53 +0700 Subject: [PATCH 12/13] docs: extract breaking section into a separate section --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce70d50e..2ebde1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- `TestingCatalogCard` now skips the extra spacing above category chips when a test has no categories. + +### Breaking + - Shared test-attempt transport DTOs were renamed from guest-prefixed names to neutral request/response models because the same payload shapes are now reused by both guest and authenticated flows. - Test-attempt DI wiring now resolves separate guest and authenticated repository bindings while keeping the shared `TestAttemptCubit` and domain contract unchanged. -- `TestingCatalogCard` now skips the extra spacing above category chips when a test has no categories. ## [0.3.1] - 2026-03-25 From e96a7946f01ccf93ee4e7ba44a241846672b0cf1 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 14:06:07 +0700 Subject: [PATCH 13/13] test(test-attempt): add missing test case for general exception for saveResult method. --- ...ted_test_attempt_repository_impl_test.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart index 4edbd0f2..a46e1220 100644 --- a/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart +++ b/test/features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl_test.dart @@ -163,6 +163,25 @@ void main() { verify(apiClient.saveTestResult(any, any)).called(1); verifyNoMoreInteractions(apiClient); }); + + test('returns UnknownTestsFailure when unexpected exception occurs', () async { + final exception = Exception('unexpected_error'); + when(apiClient.saveTestResult(any, any)).thenThrow(exception); + + final result = await repository.saveResult( + attemptId: '11', + testingExerciseId: 16, + resultValue: 2, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveTestResult(any, any)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); }); group('AuthenticatedTestAttemptRepositoryImpl.completeTest', () {