From 1fc9eda5242c465d7c30346132427dadd09ad0ba Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:44:42 +0700 Subject: [PATCH 01/13] feat(profile): implement profile user section with dialogs (#52) * build(deps): add image_picker for profile avatar flow * feat(profile): add profile api contract * feat(profile): add profile repository layer * test(profile): add repository coverage * feat(profile): add profile presentation cubits * test(profile): add cubit coverage * feat(profile): add profile user section ui * docs(changelog): document profile user section * chore(deps): update file_picker to the latest version * fix(profile): map auth failures as request failures * fix(profile): harden user section interactions --- CHANGELOG.md | 2 + assets/icons/notification.svg | 3 + ios/Podfile.lock | 6 + lib/core/constants/app_assets.dart | 1 + lib/core/constants/app_strings.dart | 21 ++ lib/core/di/di.dart | 10 + .../feature/profile/profile_failure.dart | 41 +++ lib/core/network/api_paths.dart | 9 + lib/core/router/router.dart | 34 +- .../cubits/auth_session_cubit.dart | 11 + .../data/dto/change_password_request_dto.dart | 29 ++ .../data/dto/profile_user_data_dto.dart | 21 ++ .../profile/data/dto/profile_user_dto.dart | 41 +++ .../data/dto/profile_user_response_dto.dart | 21 ++ .../data/dto/update_profile_request_dto.dart | 22 ++ .../data/mappers/profile_failure_mapper.dart | 52 +++ .../mappers/profile_user_entity_mapper.dart | 13 + .../data/remote/profile_api_client.dart | 33 ++ .../repositories/profile_repository_impl.dart | 113 ++++++ .../repositories/profile_repository.dart | 24 ++ .../cubits/change_password_cubit.dart | 46 +++ .../cubits/change_password_state.dart | 17 + .../cubits/profile_user_cubit.dart | 66 ++++ .../cubits/profile_user_state.dart | 12 + .../cubits/update_profile_cubit.dart | 49 +++ .../cubits/update_profile_state.dart | 17 + .../presentation/pages/profile_page.dart | 144 ++++++++ .../pages/profile_page_builder.dart | 32 ++ .../widgets/change_password_dialog.dart | 177 ++++++++++ .../widgets/edit_profile_dialog.dart | 291 ++++++++++++++++ .../widgets/profile_dialog_shell.dart | 58 ++++ .../widgets/user_section_widget.dart | 87 +++++ pubspec.lock | 112 ++++++ pubspec.yaml | 1 + .../cubits/auth_session_cubit_test.dart | 32 ++ .../mappers/profile_failure_mapper_test.dart | 74 ++++ .../profile_repository_impl_test.dart | 326 ++++++++++++++++++ .../cubits/change_password_cubit_test.dart | 117 +++++++ .../cubits/profile_user_cubit_test.dart | 111 ++++++ .../cubits/update_profile_cubit_test.dart | 136 ++++++++ .../profile/support/profile_dto_fixtures.dart | 78 +++++ 41 files changed, 2464 insertions(+), 26 deletions(-) create mode 100644 assets/icons/notification.svg create mode 100644 lib/core/failures/feature/profile/profile_failure.dart create mode 100644 lib/features/profile/data/dto/change_password_request_dto.dart create mode 100644 lib/features/profile/data/dto/profile_user_data_dto.dart create mode 100644 lib/features/profile/data/dto/profile_user_dto.dart create mode 100644 lib/features/profile/data/dto/profile_user_response_dto.dart create mode 100644 lib/features/profile/data/dto/update_profile_request_dto.dart create mode 100644 lib/features/profile/data/mappers/profile_failure_mapper.dart create mode 100644 lib/features/profile/data/mappers/profile_user_entity_mapper.dart create mode 100644 lib/features/profile/data/remote/profile_api_client.dart create mode 100644 lib/features/profile/data/repositories/profile_repository_impl.dart create mode 100644 lib/features/profile/domain/repositories/profile_repository.dart create mode 100644 lib/features/profile/presentation/cubits/change_password_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/change_password_state.dart create mode 100644 lib/features/profile/presentation/cubits/profile_user_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_user_state.dart create mode 100644 lib/features/profile/presentation/cubits/update_profile_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/update_profile_state.dart create mode 100644 lib/features/profile/presentation/pages/profile_page.dart create mode 100644 lib/features/profile/presentation/pages/profile_page_builder.dart create mode 100644 lib/features/profile/presentation/widgets/change_password_dialog.dart create mode 100644 lib/features/profile/presentation/widgets/edit_profile_dialog.dart create mode 100644 lib/features/profile/presentation/widgets/profile_dialog_shell.dart create mode 100644 lib/features/profile/presentation/widgets/user_section_widget.dart create mode 100644 test/features/profile/data/mappers/profile_failure_mapper_test.dart create mode 100644 test/features/profile/data/repositories/profile_repository_impl_test.dart create mode 100644 test/features/profile/presentation/cubits/change_password_cubit_test.dart create mode 100644 test/features/profile/presentation/cubits/profile_user_cubit_test.dart create mode 100644 test/features/profile/presentation/cubits/update_profile_cubit_test.dart create mode 100644 test/features/profile/support/profile_dto_fixtures.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebde1f0..862e2dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout. ### Changed @@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- The `/profile` root tab now renders the real user-section screen instead of the previous placeholder, reuses the authenticated session user as an initial seed, and keeps forgot-password routes reachable from the change-password dialog for authenticated users. ### Breaking diff --git a/assets/icons/notification.svg b/assets/icons/notification.svg new file mode 100644 index 00000000..435326d0 --- /dev/null +++ b/assets/icons/notification.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e544677c..2da951e4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -5,6 +5,8 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - image_picker_ios (0.0.1): + - Flutter - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS @@ -13,6 +15,7 @@ DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: @@ -22,6 +25,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" @@ -29,6 +34,7 @@ SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 98ec585a..3e33b4e1 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -14,6 +14,7 @@ abstract final class AppAssets { static const iconSearch = 'search'; static const iconFilter = 'filter'; static const iconClose = 'close'; + static const iconNotification = 'notification'; static const iconBadFace = 'bad_face'; static const iconNormalFace = 'normal_face'; static const iconGoodFace = 'good_face'; diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index 4dd24353..55a5723f 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -242,6 +242,27 @@ abstract final class AppStrings { static const workoutExecutionWeightSecondary = 'Пропустить'; static const workoutExecutionWeightInvalid = 'Введите корректный вес в килограммах'; + // Profile. + static String profileGreeting(String name) => 'Здравствуйте, $name'; + static const profileEditButton = 'Редактировать профиль'; + static const profileChangePasswordButton = 'Сменить пароль'; + static const profileChangePasswordTitle = 'Смена пароля'; + static const profileOldPasswordLabel = 'Старый пароль'; + static const profileNewPasswordLabel = 'Новый пароль'; + static const profilePasswordConfirmationLabel = 'Подтверждение пароля'; + static const profileUploadFileLabel = 'Загрузить файл:'; + static const profileUploadFormatPlaceholder = 'jpg формат'; + static const profileEditEmailLabel = 'Введите email'; + static const profileEditNameLabel = 'Введите имя'; + static const profileSaveButton = 'Сохранить'; + static const profileCancelButton = 'Отменить'; + static const profileLoadFailed = 'Не удалось загрузить профиль'; + static const profileValidationFailed = 'Проверьте введенные данные и попробуйте снова'; + static const profileUpdateFailed = 'Не удалось обновить профиль. Попробуйте снова'; + static const profileChangePasswordFailed = 'Не удалось сменить пароль. Попробуйте снова'; + static const profileImagePickFailed = 'Не удалось выбрать изображение. Попробуйте снова'; + static const profileUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + /// Builds the increase-adjustment message for a new absolute weight value. static String workoutExecutionAdjustmentIncrease(String weight) => 'На следующем подходе увеличьте вес до $weight $workoutExecutionWeightHint'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 98fdff9c..4c34e725 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -17,6 +17,9 @@ 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/profile/data/remote/profile_api_client.dart'; +import '../../features/profile/data/repositories/profile_repository_impl.dart'; +import '../../features/profile/domain/repositories/profile_repository.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'; @@ -112,6 +115,13 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton(() => ProfileApiClient(di())); + di.registerLazySingleton( + () => ProfileRepositoryImpl( + di(), + di(), + ), + ); // Fitness Start di.registerLazySingleton(() => FitnessStartApiClient(di())); diff --git a/lib/core/failures/feature/profile/profile_failure.dart b/lib/core/failures/feature/profile/profile_failure.dart new file mode 100644 index 00000000..bbf8696f --- /dev/null +++ b/lib/core/failures/feature/profile/profile_failure.dart @@ -0,0 +1,41 @@ +import '../../../constants/app_strings.dart'; +import '../../app_failure.dart'; + +/// Profile application error. +sealed class ProfileFailure extends AppFailure { + /// Creates an instance of [ProfileFailure]. + const ProfileFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Profile validation failed because the provided input is invalid. +final class ProfileValidationFailure extends ProfileFailure { + /// Creates an instance of [ProfileValidationFailure]. + const ProfileValidationFailure({ + String message = AppStrings.profileValidationFailed, + super.parentException, + super.stackTrace, + }) : super(message); +} + +/// Profile request failed because of infrastructure or network conditions. +final class ProfileRequestFailure extends ProfileFailure { + /// Creates an instance of [ProfileRequestFailure]. + const ProfileRequestFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Unknown profile failure. +final class UnknownProfileFailure extends ProfileFailure { + /// Creates an instance of [UnknownProfileFailure]. + const UnknownProfileFailure({ + super.parentException, + super.stackTrace, + }) : super(AppStrings.profileUnknown); +} diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 06fcd002..66f2b35f 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -41,6 +41,15 @@ abstract class ApiPaths { /// The endpoint for the current user profile. static const String me = '${apiPrefix}me'; + /// The endpoint for the authenticated profile payload. + static const String profile = '${apiPrefix}profile'; + + /// The endpoint for changing the authenticated user password. + static const String profileChangePassword = '$profile/change-password'; + + /// The endpoint for uploading or deleting the authenticated user avatar. + static const String profileAvatar = '$profile/avatar'; + /// The endpoint for all user-parameters references. static const String userParameterReferences = '${apiPrefix}user-parameters/references'; diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index d7d58194..f9ec2d0c 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../../core/constants/app_strings.dart'; import '../../features/auth/presentation/cubits/auth_session_cubit.dart'; import '../../features/auth/presentation/pages/forgot_password_page_builder.dart'; import '../../features/auth/presentation/pages/legal_document_page.dart'; @@ -21,6 +20,7 @@ import '../../features/fitness_start/presentation/pages/fitness_start_test_attem import '../../features/fitness_start/presentation/pages/fitness_start_tests_page_builder.dart'; import '../../features/offline/presentation/cubit/network_cubit.dart'; import '../../features/offline/presentation/pages/offline_page.dart'; +import '../../features/profile/presentation/pages/profile_page_builder.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'; @@ -29,8 +29,6 @@ import '../../features/workouts/details/presentation/pages/workout_details_page_ import '../../features/workouts/execution/domain/entities/workout_execution_entry_mode.dart'; import '../../features/workouts/execution/presentation/pages/workout_execution_page_builder.dart'; import '../../features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart'; -import '../../uikit/themes/colors/app_color_theme.dart'; -import '../../uikit/themes/text/app_text_theme.dart'; import '../di/di.dart'; import '../utils/analytics/app_analytics.dart'; import 'analytics_route_observer.dart'; @@ -60,6 +58,11 @@ bool _isGuestCompletedAllowedPath(String path) => path == AppRoutePaths.resetPasswordPath || path == AppRoutePaths.legalDocumentPath; +bool _isAuthenticatedAllowedAuthPath(String path) => + path == AppRoutePaths.forgotPasswordPath || + path == AppRoutePaths.verifyResetCodePath || + path == AppRoutePaths.resetPasswordPath; + /// Determines the redirect path based on the current [networkState], [authState] and [state]. String? _redirect( NetworkState networkState, @@ -142,6 +145,7 @@ String? _redirectByAuth( state.matchedLocation == AppRoutePaths.debugPath) { return null; } + if (_isAuthenticatedAllowedAuthPath(state.matchedLocation)) return null; if (isSplashScreen || isAuthScreen || isFitnessStartScreen) { return AppRoutePaths.workoutsPath; } @@ -245,9 +249,7 @@ final router = GoRouter( routes: [ GoRoute( path: AppRoutePaths.profilePath, - builder: (_, _) => const _RootPlaceholderScreen( - screenName: AppStrings.profileTab, - ), + builder: (_, _) => const ProfilePageBuilder(), ), ], ), @@ -388,23 +390,3 @@ class CombinedRouterRefreshListenable extends ChangeNotifier { super.dispose(); } } - -final class _RootPlaceholderScreen extends StatelessWidget { - final String screenName; - - const _RootPlaceholderScreen({required this.screenName}); - - @override - Widget build(BuildContext context) { - final textTheme = AppTextTheme.of(context); - final colorTheme = AppColorTheme.of(context); - return Scaffold( - body: Center( - child: Text( - screenName, - style: textTheme.title.copyWith(color: colorTheme.onSurface), - ), - ), - ); - } -} diff --git a/lib/features/auth/presentation/cubits/auth_session_cubit.dart b/lib/features/auth/presentation/cubits/auth_session_cubit.dart index 8c4fe84b..4413a5a3 100644 --- a/lib/features/auth/presentation/cubits/auth_session_cubit.dart +++ b/lib/features/auth/presentation/cubits/auth_session_cubit.dart @@ -248,6 +248,17 @@ final class AuthSessionCubit extends Cubit { emit(AuthSessionState.authenticated(user)); } + /// Updates the authenticated user payload without changing the auth mode. + void updateAuthenticatedUser(User user) { + final canUpdate = state.maybeWhen( + authenticated: (_) => true, + orElse: () => false, + ); + if (!canUpdate || isClosed) return; + + emit(AuthSessionState.authenticated(user)); + } + /// Marks the current session as unauthenticated. Future clearSession() async { final isCleared = await _clearGuestDataSafely(); diff --git a/lib/features/profile/data/dto/change_password_request_dto.dart b/lib/features/profile/data/dto/change_password_request_dto.dart new file mode 100644 index 00000000..3b0edc74 --- /dev/null +++ b/lib/features/profile/data/dto/change_password_request_dto.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'change_password_request_dto.g.dart'; + +/// DTO for changing the authenticated user password. +@JsonSerializable(createFactory: false) +class ChangePasswordRequestDto { + /// Current password. + @JsonKey(name: 'old_password') + final String oldPassword; + + /// New password. + @JsonKey(name: 'new_password') + final String newPassword; + + /// New password confirmation. + @JsonKey(name: 'new_password_confirmation') + final String newPasswordConfirmation; + + /// Creates an instance of [ChangePasswordRequestDto]. + ChangePasswordRequestDto({ + required this.oldPassword, + required this.newPassword, + required this.newPasswordConfirmation, + }); + + /// Converts [ChangePasswordRequestDto] to JSON. + Map toJson() => _$ChangePasswordRequestDtoToJson(this); +} diff --git a/lib/features/profile/data/dto/profile_user_data_dto.dart b/lib/features/profile/data/dto/profile_user_data_dto.dart new file mode 100644 index 00000000..973d099c --- /dev/null +++ b/lib/features/profile/data/dto/profile_user_data_dto.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'profile_user_dto.dart'; + +part 'profile_user_data_dto.g.dart'; + +/// DTO for the profile `data` payload. +@JsonSerializable(createToJson: false) +class ProfileUserDataDto { + /// Current authenticated user. + final ProfileUserDto user; + + /// Creates an instance of [ProfileUserDataDto]. + ProfileUserDataDto({ + required this.user, + }); + + /// Creates a [ProfileUserDataDto] from JSON. + factory ProfileUserDataDto.fromJson(Map json) => + _$ProfileUserDataDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/profile_user_dto.dart b/lib/features/profile/data/dto/profile_user_dto.dart new file mode 100644 index 00000000..a371a7d9 --- /dev/null +++ b/lib/features/profile/data/dto/profile_user_dto.dart @@ -0,0 +1,41 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_user_dto.g.dart'; + +/// DTO for the authenticated profile user payload. +@JsonSerializable(createToJson: false) +class ProfileUserDto { + /// Unique identifier for the user. + final int id; + + /// Name of the user. + final String name; + + /// Email address of the user. + final String email; + + /// Public avatar URL. + @JsonKey(name: 'avatar_url') + final String? avatarUrl; + + /// Account creation timestamp. + @JsonKey(name: 'created_at') + final String createdAt; + + /// Whether the email is verified. + @JsonKey(name: 'email_verified') + final bool emailVerified; + + /// Creates an instance of [ProfileUserDto]. + ProfileUserDto({ + required this.id, + required this.name, + required this.email, + required this.avatarUrl, + required this.createdAt, + required this.emailVerified, + }); + + /// Creates a [ProfileUserDto] from JSON. + factory ProfileUserDto.fromJson(Map json) => _$ProfileUserDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/profile_user_response_dto.dart b/lib/features/profile/data/dto/profile_user_response_dto.dart new file mode 100644 index 00000000..b551d166 --- /dev/null +++ b/lib/features/profile/data/dto/profile_user_response_dto.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'profile_user_data_dto.dart'; + +part 'profile_user_response_dto.g.dart'; + +/// DTO for the authenticated profile response. +@JsonSerializable(createToJson: false) +class ProfileUserResponseDto { + /// Nested data payload. + final ProfileUserDataDto data; + + /// Creates an instance of [ProfileUserResponseDto]. + ProfileUserResponseDto({ + required this.data, + }); + + /// Creates a [ProfileUserResponseDto] from JSON. + factory ProfileUserResponseDto.fromJson(Map json) => + _$ProfileUserResponseDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/update_profile_request_dto.dart b/lib/features/profile/data/dto/update_profile_request_dto.dart new file mode 100644 index 00000000..70cf791b --- /dev/null +++ b/lib/features/profile/data/dto/update_profile_request_dto.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'update_profile_request_dto.g.dart'; + +/// DTO for updating the authenticated profile. +@JsonSerializable(createFactory: false) +class UpdateProfileRequestDto { + /// New profile name. + final String name; + + /// New profile email. + final String email; + + /// Creates an instance of [UpdateProfileRequestDto]. + UpdateProfileRequestDto({ + required this.name, + required this.email, + }); + + /// Converts [UpdateProfileRequestDto] to JSON. + Map toJson() => _$UpdateProfileRequestDtoToJson(this); +} diff --git a/lib/features/profile/data/mappers/profile_failure_mapper.dart b/lib/features/profile/data/mappers/profile_failure_mapper.dart new file mode 100644 index 00000000..df2ba632 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_failure_mapper.dart @@ -0,0 +1,52 @@ +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/failures/helpers/validation_message_builder.dart'; +import '../../../../core/failures/network/network_failure.dart'; + +/// Extension to map [NetworkFailure] into [ProfileFailure]. +extension ProfileFailureMapper on NetworkFailure { + /// Maps a [NetworkFailure] into a profile-specific failure. + ProfileFailure toProfileFailure() { + final fieldErrors = switch (this) { + ValidationFailure(:final errors) => errors, + _ => const >{}, + }; + final validationMessage = buildValidationMessage( + fieldErrors, + fallbackMessage: const ProfileValidationFailure().message, + ); + + switch (code) { + case 'validation_failed': + return ProfileValidationFailure( + message: validationMessage, + parentException: parentException, + stackTrace: stackTrace, + ); + case 'token_expired': + case 'session_expired_inactivity': + case 'session_expired_absolute': + case 'unauthorized': + default: + return switch (this) { + NoNetworkFailure() || + ConnectionTimeoutFailure() || + BadRequestFailure() || + UnauthorizedFailure() || + ForbiddenFailure() || + NotFoundFailure() || + ConflictFailure() || + RateLimitedFailure() || + ServerErrorFailure() || + UnknownNetworkFailure() => ProfileRequestFailure( + message, + parentException: parentException, + stackTrace: stackTrace, + ), + _ => UnknownProfileFailure( + parentException: parentException, + stackTrace: stackTrace, + ), + }; + } + } +} diff --git a/lib/features/profile/data/mappers/profile_user_entity_mapper.dart b/lib/features/profile/data/mappers/profile_user_entity_mapper.dart new file mode 100644 index 00000000..fd2d4d42 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_user_entity_mapper.dart @@ -0,0 +1,13 @@ +import '../../../auth/domain/entities/user.dart'; +import '../dto/profile_user_dto.dart'; + +/// Extension to map [ProfileUserDto] into the shared auth [User] entity. +extension ProfileUserEntityMapper on ProfileUserDto { + /// Maps [ProfileUserDto] to [User]. + User toEntity() => User( + id: id, + name: name, + email: email, + avatar: avatarUrl, + ); +} diff --git a/lib/features/profile/data/remote/profile_api_client.dart b/lib/features/profile/data/remote/profile_api_client.dart new file mode 100644 index 00000000..10fbb31a --- /dev/null +++ b/lib/features/profile/data/remote/profile_api_client.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/change_password_request_dto.dart'; +import '../dto/profile_user_response_dto.dart'; +import '../dto/update_profile_request_dto.dart'; + +part 'profile_api_client.g.dart'; + +/// Retrofit API client for authenticated profile operations. +@RestApi() +abstract class ProfileApiClient { + /// Creates an instance of [ProfileApiClient]. + factory ProfileApiClient(Dio dio, {String? baseUrl}) = _ProfileApiClient; + + /// Returns the authenticated profile payload. + @GET(ApiPaths.profile) + Future getProfile(); + + /// Updates the authenticated user profile fields. + @PUT(ApiPaths.profile) + Future updateProfile(@Body() UpdateProfileRequestDto request); + + /// Changes the authenticated user password. + @POST(ApiPaths.profileChangePassword) + Future changePassword(@Body() ChangePasswordRequestDto request); + + /// Uploads or replaces the authenticated user avatar. + @MultiPart() + @POST(ApiPaths.profileAvatar) + Future uploadAvatar(@Part(name: 'avatar') MultipartFile avatar); +} diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart new file mode 100644 index 00000000..a96fddf1 --- /dev/null +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/logger/app_logger.dart'; +import '../../../auth/domain/entities/user.dart'; +import '../../domain/repositories/profile_repository.dart'; +import '../dto/change_password_request_dto.dart'; +import '../dto/update_profile_request_dto.dart'; +import '../mappers/profile_failure_mapper.dart'; +import '../mappers/profile_user_entity_mapper.dart'; +import '../remote/profile_api_client.dart'; + +/// Implementation of [ProfileRepository]. +final class ProfileRepositoryImpl implements ProfileRepository { + final AppLogger _logger; + final ProfileApiClient _apiClient; + + /// Creates an instance of [ProfileRepositoryImpl]. + ProfileRepositoryImpl(this._logger, this._apiClient); + + @override + Future> getUser() async { + try { + final response = await _apiClient.getProfile(); + return Result.success(response.data.user.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetUser failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> updateUser({ + required User currentUser, + required String name, + required String email, + String? avatarPath, + }) async { + final normalizedName = name.trim(); + final normalizedEmail = email.trim(); + final normalizedAvatarPath = avatarPath?.trim(); + final hasProfileChanges = + normalizedName != currentUser.name || normalizedEmail != currentUser.email; + final hasAvatarChange = normalizedAvatarPath != null && normalizedAvatarPath.isNotEmpty; + + if (!hasProfileChanges && !hasAvatarChange) { + return Result.success(currentUser); + } + + try { + if (hasAvatarChange) { + final multipartFile = await MultipartFile.fromFile( + normalizedAvatarPath, + filename: normalizedAvatarPath.split(Platform.pathSeparator).last, + ); + await _apiClient.uploadAvatar(multipartFile); + } + + if (hasProfileChanges) { + final request = UpdateProfileRequestDto( + name: normalizedName, + email: normalizedEmail, + ); + await _apiClient.updateProfile(request); + } + + final refreshedResponse = await _apiClient.getProfile(); + return Result.success(refreshedResponse.data.user.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('UpdateUser failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> changePassword({ + required String oldPassword, + required String newPassword, + required String newPasswordConfirmation, + }) async { + try { + final request = ChangePasswordRequestDto( + oldPassword: oldPassword, + newPassword: newPassword, + newPasswordConfirmation: newPasswordConfirmation, + ); + await _apiClient.changePassword(request); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('ChangePassword failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } +} diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart new file mode 100644 index 00000000..b8f1f18c --- /dev/null +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -0,0 +1,24 @@ +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../../auth/domain/entities/user.dart'; + +/// Repository interface for authenticated profile operations. +abstract interface class ProfileRepository { + /// Returns the current authenticated user from the profile payload. + Future> getUser(); + + /// Updates the current user profile and returns the canonical refreshed user payload. + Future> updateUser({ + required User currentUser, + required String name, + required String email, + String? avatarPath, + }); + + /// Changes the current authenticated user password. + Future> changePassword({ + required String oldPassword, + required String newPassword, + required String newPasswordConfirmation, + }); +} diff --git a/lib/features/profile/presentation/cubits/change_password_cubit.dart b/lib/features/profile/presentation/cubits/change_password_cubit.dart new file mode 100644 index 00000000..5d269d68 --- /dev/null +++ b/lib/features/profile/presentation/cubits/change_password_cubit.dart @@ -0,0 +1,46 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../domain/repositories/profile_repository.dart'; + +part 'change_password_cubit.freezed.dart'; +part 'change_password_state.dart'; + +/// Cubit that manages the change-password flow. +final class ChangePasswordCubit extends Cubit { + final ProfileRepository _repository; + + /// Creates an instance of [ChangePasswordCubit]. + ChangePasswordCubit(this._repository) : super(const ChangePasswordState.initial()); + + /// Attempts to change the current authenticated user password. + Future changePassword({ + required String oldPassword, + required String newPassword, + required String newPasswordConfirmation, + }) async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const ChangePasswordState.inProgress()); + + final result = await _repository.changePassword( + oldPassword: oldPassword, + newPassword: newPassword, + newPasswordConfirmation: newPasswordConfirmation, + ); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const ChangePasswordState.succeed()); + case Failure(:final error): + emit(ChangePasswordState.failed(error)); + } + } +} diff --git a/lib/features/profile/presentation/cubits/change_password_state.dart b/lib/features/profile/presentation/cubits/change_password_state.dart new file mode 100644 index 00000000..6c3be339 --- /dev/null +++ b/lib/features/profile/presentation/cubits/change_password_state.dart @@ -0,0 +1,17 @@ +part of 'change_password_cubit.dart'; + +/// States for [ChangePasswordCubit]. +@freezed +class ChangePasswordState with _$ChangePasswordState { + /// Initial idle state. + const factory ChangePasswordState.initial() = _Initial; + + /// Submit is in progress. + const factory ChangePasswordState.inProgress() = _InProgress; + + /// Password change succeeded. + const factory ChangePasswordState.succeed() = _Succeed; + + /// Password change failed. + const factory ChangePasswordState.failed(ProfileFailure failure) = _Failed; +} diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart new file mode 100644 index 00000000..745c52fe --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart @@ -0,0 +1,66 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../../auth/domain/entities/user.dart'; +import '../../domain/repositories/profile_repository.dart'; + +part 'profile_user_cubit.freezed.dart'; +part 'profile_user_state.dart'; + +/// Cubit that manages the authenticated profile user section payload. +final class ProfileUserCubit extends Cubit { + final ProfileRepository _repository; + + /// Creates an instance of [ProfileUserCubit]. + ProfileUserCubit( + this._repository, { + User? seedUser, + }) : super(ProfileUserState(user: seedUser)); + + /// Refreshes the canonical profile user payload. + Future refresh() async { + if (state.isLoading) return; + + emit( + state.copyWith( + isLoading: true, + failure: null, + ), + ); + + final result = await _repository.getUser(); + if (isClosed) return; + + switch (result) { + case Success(data: final user): + emit( + state.copyWith( + isLoading: false, + user: user, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } + + /// Replaces the current user with a freshly updated payload. + void replaceUser(User user) { + if (isClosed) return; + + emit( + state.copyWith( + user: user, + failure: null, + ), + ); + } +} diff --git a/lib/features/profile/presentation/cubits/profile_user_state.dart b/lib/features/profile/presentation/cubits/profile_user_state.dart new file mode 100644 index 00000000..a070f8b8 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_user_state.dart @@ -0,0 +1,12 @@ +part of 'profile_user_cubit.dart'; + +/// State for [ProfileUserCubit]. +@freezed +abstract class ProfileUserState with _$ProfileUserState { + /// Creates an instance of [ProfileUserState]. + const factory ProfileUserState({ + @Default(false) bool isLoading, + User? user, + ProfileFailure? failure, + }) = _ProfileUserState; +} diff --git a/lib/features/profile/presentation/cubits/update_profile_cubit.dart b/lib/features/profile/presentation/cubits/update_profile_cubit.dart new file mode 100644 index 00000000..25d70f42 --- /dev/null +++ b/lib/features/profile/presentation/cubits/update_profile_cubit.dart @@ -0,0 +1,49 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../../auth/domain/entities/user.dart'; +import '../../domain/repositories/profile_repository.dart'; + +part 'update_profile_cubit.freezed.dart'; +part 'update_profile_state.dart'; + +/// Cubit that manages edit-profile submit flow. +final class UpdateProfileCubit extends Cubit { + final ProfileRepository _repository; + + /// Creates an instance of [UpdateProfileCubit]. + UpdateProfileCubit(this._repository) : super(const UpdateProfileState.initial()); + + /// Updates the authenticated profile. + Future updateProfile({ + required User currentUser, + required String name, + required String email, + String? avatarPath, + }) async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const UpdateProfileState.inProgress()); + + final result = await _repository.updateUser( + currentUser: currentUser, + name: name, + email: email, + avatarPath: avatarPath, + ); + if (isClosed) return; + + switch (result) { + case Success(data: final user): + emit(UpdateProfileState.succeed(user)); + case Failure(:final error): + emit(UpdateProfileState.failed(error)); + } + } +} diff --git a/lib/features/profile/presentation/cubits/update_profile_state.dart b/lib/features/profile/presentation/cubits/update_profile_state.dart new file mode 100644 index 00000000..908c3b8a --- /dev/null +++ b/lib/features/profile/presentation/cubits/update_profile_state.dart @@ -0,0 +1,17 @@ +part of 'update_profile_cubit.dart'; + +/// States for [UpdateProfileCubit]. +@freezed +class UpdateProfileState with _$UpdateProfileState { + /// Initial idle state. + const factory UpdateProfileState.initial() = _Initial; + + /// Submit is in progress. + const factory UpdateProfileState.inProgress() = _InProgress; + + /// Submit succeeded with a refreshed canonical user payload. + const factory UpdateProfileState.succeed(User user) = _Succeed; + + /// Submit failed with a profile error. + const factory UpdateProfileState.failed(ProfileFailure failure) = _Failed; +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart new file mode 100644 index 00000000..e767018c --- /dev/null +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.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/main_button.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 '../../../auth/domain/entities/user.dart'; +import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../cubits/profile_user_cubit.dart'; +import '../widgets/change_password_dialog.dart'; +import '../widgets/edit_profile_dialog.dart'; +import '../widgets/user_section_widget.dart'; + +/// Authenticated profile page with the user section only. +class ProfilePage extends StatelessWidget { + /// Creates an instance of [ProfilePage]. + const ProfilePage({super.key}); + + Future _openEditProfileDialog(BuildContext context, User user) async { + final updatedUser = await showEditProfileDialog(context, user: user); + if (!context.mounted || updatedUser == null) return; + + context.read().replaceUser(updatedUser); + context.read().updateAuthenticatedUser(updatedUser); + } + + Future _openChangePasswordDialog(BuildContext context) async { + final result = await showChangePasswordDialog(context); + if (!context.mounted || result != ChangePasswordDialogResult.forgotPassword) return; + + unawaited(context.push(AppRoutePaths.forgotPasswordPath)); + } + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Scaffold( + appBar: AppBar( + title: Text( + AppStrings.profileTab, + style: textTheme.appBarTitle, + ), + actions: [ + const Padding( + padding: EdgeInsets.only(right: 24), + child: Center( + child: ExcludeSemantics( + child: SvgPictureWidget.icon(AppAssets.iconNotification), + ), + ), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + final user = state.user; + if (user == null) { + return _ProfileUserFallbackState( + isLoading: state.isLoading, + onRetryPressed: () => context.read().refresh(), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 132), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.profileGreeting(user.name), + style: textTheme.bodyMedium.copyWith( + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 24), + UserSectionWidget( + user: user, + onEditPressed: () => _openEditProfileDialog(context, user), + onChangePasswordPressed: () => _openChangePasswordDialog(context), + ), + ], + ), + ); + }, + ), + ); + } +} + +final class _ProfileUserFallbackState extends StatelessWidget { + final bool isLoading; + final VoidCallback onRetryPressed; + + const _ProfileUserFallbackState({ + required this.isLoading, + required this.onRetryPressed, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + + if (isLoading) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.profileLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart new file mode 100644 index 00000000..0d6b399a --- /dev/null +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/di/di.dart'; +import '../../../auth/domain/entities/user.dart'; +import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../../domain/repositories/profile_repository.dart'; +import '../cubits/profile_user_cubit.dart'; +import 'profile_page.dart'; + +/// Builder for the authenticated profile page. +class ProfilePageBuilder extends StatelessWidget { + /// Creates an instance of [ProfilePageBuilder]. + const ProfilePageBuilder({super.key}); + + @override + Widget build(BuildContext context) { + final initialUser = context.select( + (cubit) => cubit.state.maybeWhen( + authenticated: (user) => user, + orElse: () => null, + ), + ); + return BlocProvider( + create: (_) => ProfileUserCubit( + di(), + seedUser: initialUser, + )..refresh(), + child: const ProfilePage(), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/change_password_dialog.dart b/lib/features/profile/presentation/widgets/change_password_dialog.dart new file mode 100644 index 00000000..e3535172 --- /dev/null +++ b/lib/features/profile/presentation/widgets/change_password_dialog.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/di/di.dart'; +import '../../../../../uikit/buttons/app_text_action.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../auth/presentation/validators/auth_validators.dart'; +import '../../../auth/presentation/widgets/auth_password_field.dart'; +import '../../domain/repositories/profile_repository.dart'; +import '../cubits/change_password_cubit.dart'; +import 'profile_dialog_shell.dart'; + +/// Result returned by the change-password dialog. +enum ChangePasswordDialogResult { + /// The password was changed successfully. + changed, + + /// The user wants to enter the forgot-password flow instead. + forgotPassword, +} + +/// Opens the change-password dialog. +Future showChangePasswordDialog(BuildContext context) { + return showProfileDialog( + context, + insetPadding: const EdgeInsets.symmetric(horizontal: 19.5), + child: BlocProvider( + create: (_) => ChangePasswordCubit(di()), + child: const ChangePasswordDialog(), + ), + ); +} + +/// Dialog for changing the authenticated user password. +class ChangePasswordDialog extends StatefulWidget { + /// Creates an instance of [ChangePasswordDialog]. + const ChangePasswordDialog({super.key}); + + @override + State createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends State { + final _formKey = GlobalKey(); + final _oldPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _newPasswordConfirmationController = TextEditingController(); + + @override + void dispose() { + _oldPasswordController.dispose(); + _newPasswordController.dispose(); + _newPasswordConfirmationController.dispose(); + super.dispose(); + } + + String? _confirmationValidator(String? value) { + if (value == null || value.isEmpty) { + return AppStrings.resetPasswordPasswordConfirmationRequired; + } + if (value != _newPasswordController.text) { + return AppStrings.resetPasswordPasswordMismatch; + } + return null; + } + + void _submit() { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + context.read().changePassword( + oldPassword: _oldPasswordController.text, + newPassword: _newPasswordController.text, + newPasswordConfirmation: _newPasswordConfirmationController.text, + ); + } + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + return BlocConsumer( + listener: (context, state) { + state.whenOrNull( + succeed: () => Navigator.of(context).pop(ChangePasswordDialogResult.changed), + failed: (failure) { + if (failure.message.isEmpty) return; + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ); + }, + ); + }, + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 10), + Text( + AppStrings.profileChangePasswordTitle, + textAlign: TextAlign.center, + style: textTheme.title.copyWith( + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 40), + AuthPasswordField( + controller: _oldPasswordController, + enabled: !isInProgress, + labelText: AppStrings.profileOldPasswordLabel, + textInputAction: TextInputAction.next, + validator: AuthValidators.password, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: AppTextAction( + text: AppStrings.signInForgotPasswordButton, + onPressed: isInProgress + ? null + : () => Navigator.of( + context, + ).pop(ChangePasswordDialogResult.forgotPassword), + style: textTheme.bodyMedium, + ), + ), + const SizedBox(height: 12), + AuthPasswordField( + controller: _newPasswordController, + enabled: !isInProgress, + labelText: AppStrings.profileNewPasswordLabel, + textInputAction: TextInputAction.next, + validator: AuthValidators.password, + ), + const SizedBox(height: 12), + AuthPasswordField( + controller: _newPasswordConfirmationController, + enabled: !isInProgress, + labelText: AppStrings.profilePasswordConfirmationLabel, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _confirmationValidator, + ), + const SizedBox(height: 36), + MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: _submit, + child: const Text(AppStrings.profileChangePasswordButton), + ), + const SizedBox(height: 12), + SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: () => Navigator.of(context).pop(), + child: const Text(AppStrings.profileCancelButton), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart new file mode 100644 index 00000000..40ae96a1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/di/di.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.dart'; +import '../../../../../uikit/images/network_image_widget.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../auth/domain/entities/user.dart'; +import '../../../auth/presentation/validators/auth_validators.dart'; +import '../../../auth/presentation/widgets/auth_text_field.dart'; +import '../../domain/repositories/profile_repository.dart'; +import '../cubits/update_profile_cubit.dart'; +import 'profile_dialog_shell.dart'; + +/// Opens the edit-profile dialog and returns the refreshed user on success. +Future showEditProfileDialog( + BuildContext context, { + required User user, +}) { + return showProfileDialog( + context, + insetPadding: const EdgeInsets.symmetric(horizontal: 11.5), + child: BlocProvider( + create: (_) => UpdateProfileCubit(di()), + child: EditProfileDialog(user: user), + ), + ); +} + +/// Dialog for editing the authenticated profile. +class EditProfileDialog extends StatefulWidget { + /// Current authenticated user. + final User user; + + /// Creates an instance of [EditProfileDialog]. + const EditProfileDialog({ + required this.user, + super.key, + }); + + @override + State createState() => _EditProfileDialogState(); +} + +class _EditProfileDialogState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _nameController = TextEditingController(); + final _picker = ImagePicker(); + + String? _selectedAvatarPath; + + @override + void initState() { + super.initState(); + _emailController.text = widget.user.email; + _nameController.text = widget.user.name; + } + + @override + void dispose() { + _emailController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _pickAvatar() async { + try { + final file = await _picker.pickImage( + source: ImageSource.gallery, + ); + if (file == null || !mounted) return; + + setState(() { + _selectedAvatarPath = file.path; + }); + } catch (_) { + if (!mounted) return; + await showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: AppStrings.profileImagePickFailed, + ); + } + } + + void _submit() { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + context.read().updateProfile( + currentUser: widget.user, + name: _nameController.text, + email: _emailController.text, + avatarPath: _selectedAvatarPath, + ); + } + + String get _formatLabel { + final avatarPath = _selectedAvatarPath; + if (avatarPath == null || avatarPath.isEmpty) { + return AppStrings.profileUploadFormatPlaceholder; + } + + final fileName = avatarPath.split(Platform.pathSeparator).last; + final dotIndex = fileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex == fileName.length - 1) { + return AppStrings.profileUploadFormatPlaceholder; + } + + final extension = fileName.substring(dotIndex + 1).toLowerCase(); + return '$extension формат'; + } + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + return BlocConsumer( + listener: (context, state) { + state.whenOrNull( + succeed: (user) => Navigator.of(context).pop(user), + failed: (failure) { + if (failure.message.isEmpty) return; + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ); + }, + ); + }, + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: _AvatarPreview( + imageUrl: widget.user.avatar, + localAvatarPath: _selectedAvatarPath, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + AppStrings.profileUploadFileLabel, + style: textTheme.body.copyWith( + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + ), + _AvatarPickButton( + onPressed: isInProgress ? null : _pickAvatar, + ), + ], + ), + const SizedBox(height: 12), + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + _formatLabel, + textAlign: TextAlign.center, + style: textTheme.button.copyWith( + color: colorTheme.hint, + ), + ), + ), + ), + const SizedBox(height: 28), + AuthTextField( + controller: _emailController, + enabled: !isInProgress, + labelText: AppStrings.profileEditEmailLabel, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: AuthValidators.email, + ), + const SizedBox(height: 12), + AuthTextField( + controller: _nameController, + enabled: !isInProgress, + labelText: AppStrings.profileEditNameLabel, + keyboardType: TextInputType.name, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: AuthValidators.name, + ), + const SizedBox(height: 36), + MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: _submit, + child: const Text(AppStrings.profileSaveButton), + ), + const SizedBox(height: 8), + SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: () => context.pop(), + child: const Text(AppStrings.profileCancelButton), + ), + ], + ), + ); + }, + ); + } +} + +final class _AvatarPreview extends StatelessWidget { + final String? imageUrl; + final String? localAvatarPath; + + const _AvatarPreview({ + required this.imageUrl, + required this.localAvatarPath, + }); + + @override + Widget build(BuildContext context) { + final localAvatarPath = this.localAvatarPath; + if (localAvatarPath != null && localAvatarPath.isNotEmpty) { + return Image.file( + File(localAvatarPath), + height: 320, + width: double.infinity, + fit: BoxFit.cover, + ); + } + return NetworkImageWidget( + imageUrl: imageUrl ?? '', + height: 296, + ); + } +} + +final class _AvatarPickButton extends StatelessWidget { + final VoidCallback? onPressed; + + const _AvatarPickButton({required this.onPressed}); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: onPressed == null ? colorTheme.disabled : colorTheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + '+', + textAlign: TextAlign.center, + style: textTheme.button.copyWith(color: colorTheme.onPrimary), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_dialog_shell.dart b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart new file mode 100644 index 00000000..514ec8e5 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart @@ -0,0 +1,58 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../../../../../uikit/themes/colors/app_color_theme.dart'; + +/// Shows a profile-specific dialog with shared backdrop styling. +Future showProfileDialog( + BuildContext context, { + required Widget child, + required EdgeInsets insetPadding, +}) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: AppColorTheme.of(context).onSurface.withValues(alpha: 0.16), + builder: (_) => PopScope( + canPop: false, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: ProfileDialogShell( + insetPadding: insetPadding, + child: child, + ), + ), + ), + ); +} + +/// Shared rounded shell for profile dialogs. +class ProfileDialogShell extends StatelessWidget { + /// Dialog outer insets. + final EdgeInsets insetPadding; + + /// Dialog content. + final Widget child; + + /// Creates an instance of [ProfileDialogShell]. + const ProfileDialogShell({ + required this.insetPadding, + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + return Dialog( + insetPadding: insetPadding, + backgroundColor: colorTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), + child: child, + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/user_section_widget.dart b/lib/features/profile/presentation/widgets/user_section_widget.dart new file mode 100644 index 00000000..cf40f40e --- /dev/null +++ b/lib/features/profile/presentation/widgets/user_section_widget.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/cards/app_card.dart'; +import '../../../../../uikit/images/network_image_widget.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../auth/domain/entities/user.dart'; + +/// The first profile section with the basic authenticated user data. +class UserSectionWidget extends StatelessWidget { + /// Current authenticated user. + final User user; + + /// Callback for the edit-profile action. + final VoidCallback onEditPressed; + + /// Callback for the change-password action. + final VoidCallback onChangePasswordPressed; + + /// Creates an instance of [UserSectionWidget]. + const UserSectionWidget({ + required this.user, + required this.onEditPressed, + required this.onChangePasswordPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return AppCard( + contentPadding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 14), + Center( + child: ClipOval( + child: SizedBox.square( + dimension: 140, + child: NetworkImageWidget( + imageUrl: user.avatar ?? '', + height: 140, + ), + ), + ), + ), + const SizedBox(height: 14), + Text( + user.name, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith( + fontSize: 14, + height: 21 / 14, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + user.email, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith( + fontSize: 14, + height: 21 / 14, + color: colorTheme.darkHint, + ), + ), + const SizedBox(height: 24), + MainButton( + onPressed: onEditPressed, + child: const Text(AppStrings.profileEditButton), + ), + const SizedBox(height: 12), + SecondaryButton( + onPressed: onChangePasswordPressed, + child: const Text(AppStrings.profileChangePasswordButton), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index e45918db..a6d26b23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -345,6 +353,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -382,6 +422,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -560,6 +608,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd" + url: "https://pub.dev" + source: hosted + version: "0.8.13+15" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 100c53a7..06419637 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: get_it: ^9.2.0 go_router: ^17.1.0 hive_ce_flutter: ^2.3.4 + image_picker: ^1.2.1 json_annotation: ^4.10.0 logger: ^2.6.2 path_provider: ^2.1.5 diff --git a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart index 753b608f..23347e9e 100644 --- a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart +++ b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart @@ -204,6 +204,38 @@ void main() { }, ); + blocTest( + 'updateAuthenticatedUser emits authenticated(updatedUser) when session is authenticated', + build: () => authSessionCubit, + seed: () => const AuthSessionState.authenticated(user), + act: (cubit) => cubit.updateAuthenticatedUser( + const User( + id: 1, + name: 'updated_name', + email: 'updated@mail.com', + avatar: 'avatar.jpg', + ), + ), + expect: () => const [ + AuthSessionState.authenticated( + User( + id: 1, + name: 'updated_name', + email: 'updated@mail.com', + avatar: 'avatar.jpg', + ), + ), + ], + ); + + blocTest( + 'updateAuthenticatedUser is no-op when session is not authenticated', + build: () => authSessionCubit, + seed: () => const AuthSessionState.unauthenticated(), + act: (cubit) => cubit.updateAuthenticatedUser(user), + expect: () => const [], + ); + blocTest( 'startGuestFitnessStart emits guest', setUp: () => when(tokenStorage.getAccessToken()).thenAnswer((_) async => null), diff --git a/test/features/profile/data/mappers/profile_failure_mapper_test.dart b/test/features/profile/data/mappers/profile_failure_mapper_test.dart new file mode 100644 index 00000000..9bbdd09c --- /dev/null +++ b/test/features/profile/data/mappers/profile_failure_mapper_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moveup_flutter/core/constants/app_strings.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/failures/network/network_failure.dart'; +import 'package:moveup_flutter/features/profile/data/mappers/profile_failure_mapper.dart'; + +void main() { + group('ProfileFailureMapper.toProfileFailure', () { + test('maps validation_failed to ProfileValidationFailure with validation message', () { + const failure = ValidationFailure( + errors: { + 'email': ['error_message'], + }, + ); + + final result = failure.toProfileFailure(); + + expect(result, isA()); + expect(result.message, 'error_message'); + }); + + test('falls back to generic profile validation message when field errors are empty', () { + final result = const ValidationFailure().toProfileFailure(); + + expect(result, isA()); + expect(result.message, AppStrings.profileValidationFailed); + }); + + test('maps NoNetworkFailure to ProfileRequestFailure', () { + final result = const NoNetworkFailure().toProfileFailure(); + + expect(result, isA()); + expect(result.message, const NoNetworkFailure().message); + }); + + test('maps ConnectionTimeoutFailure to ProfileRequestFailure', () { + final result = const ConnectionTimeoutFailure().toProfileFailure(); + + expect(result, isA()); + expect(result.message, const ConnectionTimeoutFailure().message); + }); + + test('maps ServerErrorFailure to ProfileRequestFailure', () { + final result = const ServerErrorFailure().toProfileFailure(); + + expect(result, isA()); + expect(result.message, const ServerErrorFailure().message); + }); + + test('maps UnknownNetworkFailure to ProfileRequestFailure', () { + final result = const UnknownNetworkFailure().toProfileFailure(); + + expect(result, isA()); + expect(result.message, const UnknownNetworkFailure().message); + }); + + test('maps auth-session and access failures to ProfileRequestFailure', () { + const failures = [ + UnauthorizedFailure(code: 'token_expired'), + UnauthorizedFailure(code: 'session_expired_inactivity'), + UnauthorizedFailure(code: 'session_expired_absolute'), + UnauthorizedFailure(), + ForbiddenFailure(), + ]; + + for (final failure in failures) { + final result = failure.toProfileFailure(); + + expect(result, isA()); + expect(result.message, failure.message); + } + }); + }); +} diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart new file mode 100644 index 00000000..21fbfa69 --- /dev/null +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -0,0 +1,326 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/data/dto/change_password_request_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/update_profile_request_dto.dart'; +import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart'; +import 'package:moveup_flutter/features/profile/data/repositories/profile_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; + +import '../../support/profile_dto_fixtures.dart'; +import 'profile_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockProfileApiClient apiClient; + late ProfileRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockProfileApiClient(); + repository = ProfileRepositoryImpl(logger, apiClient); + }); + + group('ProfileRepositoryImpl', () { + group('getUser', () { + test('returns success(user) when api succeeds', () async { + // Arrange + final responseDto = createProfileUserResponseDto(); + when(apiClient.getProfile()).thenAnswer((_) async => responseDto); + + // Act + final result = await repository.getUser(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, createProfileUser()); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api returns server error', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getUser(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getUser(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('updateUser', () { + test('returns currentUser without network calls when nothing changed', () async { + // Arrange + const currentUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + avatar: testProfileUserAvatar, + ); + + // Act + final result = await repository.updateUser( + currentUser: currentUser, + name: testProfileUserName, + email: testProfileUserEmail, + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, currentUser); + verifyNever(apiClient.getProfile()); + verifyNever(apiClient.updateProfile(any)); + verifyNever(apiClient.uploadAvatar(any)); + verifyNever(apiClient.changePassword(any)); + }); + + test('updates name and email only', () async { + // Arrange + const currentUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + avatar: testProfileUserAvatar, + ); + final refreshedUser = createProfileUserDto( + name: 'test_name', + email: 'test@mail.com', + ); + when(apiClient.updateProfile(any)).thenAnswer((_) async {}); + when( + apiClient.getProfile(), + ).thenAnswer((_) async => createProfileUserResponseDto(user: refreshedUser)); + + // Act + final result = await repository.updateUser( + currentUser: currentUser, + name: 'test_name', + email: 'test@mail.com', + ); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + createProfileUser( + name: 'test_name', + email: 'test@mail.com', + ), + ); + + final captured = + verify(apiClient.updateProfile(captureAny)).captured.single as UpdateProfileRequestDto; + expect(captured.name, 'test_name'); + expect(captured.email, 'test@mail.com'); + verifyNever(apiClient.uploadAvatar(any)); + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('updates avatar only', () async { + // Arrange + const currentUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + ); + final tempDirectory = await Directory.systemTemp.createTemp('test'); + final avatarFile = File('${tempDirectory.path}/avatar.jpg'); + await avatarFile.writeAsString('avatar'); + when(apiClient.uploadAvatar(any)).thenAnswer((_) async {}); + when(apiClient.getProfile()).thenAnswer( + (_) async => createProfileUserResponseDto( + user: createProfileUserDto(avatarUrl: 'new-avatar.jpg'), + ), + ); + + addTearDown(() async { + await tempDirectory.delete(recursive: true); + }); + + // Act + final result = await repository.updateUser( + currentUser: currentUser, + name: currentUser.name, + email: currentUser.email, + avatarPath: avatarFile.path, + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.avatar, 'new-avatar.jpg'); + + verify(apiClient.uploadAvatar(any)).called(1); + verifyNever(apiClient.updateProfile(any)); + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('updates avatar and text fields in one save flow', () async { + // Arrange + const currentUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + ); + final tempDirectory = await Directory.systemTemp.createTemp('profile_repository_test'); + final avatarFile = File('${tempDirectory.path}/avatar.jpg'); + await avatarFile.writeAsString('avatar'); + when(apiClient.uploadAvatar(any)).thenAnswer((_) async {}); + when(apiClient.updateProfile(any)).thenAnswer((_) async {}); + when(apiClient.getProfile()).thenAnswer( + (_) async => createProfileUserResponseDto( + user: createProfileUserDto( + name: 'test_name', + email: 'test@mail.com', + avatarUrl: 'new-avatar.jpg', + ), + ), + ); + + addTearDown(() async { + await tempDirectory.delete(recursive: true); + }); + + // Act + final result = await repository.updateUser( + currentUser: currentUser, + name: 'test_name', + email: 'test@mail.com', + avatarPath: avatarFile.path, + ); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + createProfileUser( + name: 'test_name', + email: 'test@mail.com', + avatar: 'new-avatar.jpg', + ), + ); + + verify(apiClient.uploadAvatar(any)).called(1); + verify(apiClient.updateProfile(any)).called(1); + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('changePassword', () { + test('returns success when api succeeds', () async { + // Arrange + when(apiClient.changePassword(any)).thenAnswer((_) async {}); + + // Act + final result = await repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ); + + // Assert + expect(result.isSuccess, isTrue); + final captured = + verify(apiClient.changePassword(captureAny)).captured.single + as ChangePasswordRequestDto; + expect(captured.oldPassword, 'oldPass123'); + expect(captured.newPassword, 'newPass123'); + expect(captured.newPasswordConfirmation, 'newPass123'); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileValidationFailure when api returns 422', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile/change-password', + statusCode: 422, + code: 'validation_failed', + errors: const { + 'test': ['message'], + }, + ); + when(apiClient.changePassword(any)).thenThrow(exception); + + // Act + final result = await repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.message, 'message'); + expect(result.failure!.parentException, exception); + + verify(apiClient.changePassword(any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.changePassword(any)).thenThrow(exception); + + // Act + final result = await repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.changePassword(any)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + }); +} diff --git a/test/features/profile/presentation/cubits/change_password_cubit_test.dart b/test/features/profile/presentation/cubits/change_password_cubit_test.dart new file mode 100644 index 00000000..4c6e7698 --- /dev/null +++ b/test/features/profile/presentation/cubits/change_password_cubit_test.dart @@ -0,0 +1,117 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/change_password_cubit.dart'; + +import 'change_password_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileRepository repository; + late ChangePasswordCubit cubit; + + const failure = ProfileValidationFailure(message: 'test'); + + setUp(() { + repository = MockProfileRepository(); + cubit = ChangePasswordCubit(repository); + provideDummy>(const Success(null)); + }); + + group('ChangePasswordCubit', () { + blocTest( + 'emits inProgress and succeed when password change succeeds', + setUp: () => when( + repository.changePassword( + oldPassword: anyNamed('oldPassword'), + newPassword: anyNamed('newPassword'), + newPasswordConfirmation: anyNamed('newPasswordConfirmation'), + ), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ), + expect: () => const [ + ChangePasswordState.inProgress(), + ChangePasswordState.succeed(), + ], + verify: (_) => verify( + repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ), + ).called(1), + ); + + blocTest( + 'emits failed(failure) when password change fails', + setUp: () => when( + repository.changePassword( + oldPassword: anyNamed('oldPassword'), + newPassword: anyNamed('newPassword'), + newPasswordConfirmation: anyNamed('newPasswordConfirmation'), + ), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + act: (cubit) => cubit.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ), + expect: () => const [ + ChangePasswordState.inProgress(), + ChangePasswordState.failed(failure), + ], + verify: (_) => verify( + repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ), + ).called(1), + ); + + blocTest( + 'emits inProgress only once when changePassword is called twice', + setUp: () => when( + repository.changePassword( + oldPassword: anyNamed('oldPassword'), + newPassword: anyNamed('newPassword'), + newPasswordConfirmation: anyNamed('newPasswordConfirmation'), + ), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ); + cubit.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ); + }, + expect: () => const [ + ChangePasswordState.inProgress(), + ChangePasswordState.succeed(), + ], + verify: (_) => verify( + repository.changePassword( + oldPassword: 'oldPass123', + newPassword: 'newPass123', + newPasswordConfirmation: 'newPass123', + ), + ).called(1), + ); + }); +} diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart new file mode 100644 index 00000000..412b29e7 --- /dev/null +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -0,0 +1,111 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/profile_user_cubit.dart'; + +import '../../support/profile_dto_fixtures.dart'; +import 'profile_user_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileRepository repository; + late ProfileUserCubit cubit; + + const seedUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + avatar: testProfileUserAvatar, + ); + const updatedUser = User( + id: testProfileUserId, + name: 'test', + email: 'test@mail.com', + avatar: 'new-avatar.jpg', + ); + + setUp(() { + repository = MockProfileRepository(); + cubit = ProfileUserCubit(repository, seedUser: seedUser); + provideDummy>(const Success(seedUser)); + }); + + group('ProfileUserCubit', () { + blocTest( + 'emits loading and refreshed user when refresh succeeds', + setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)), + build: () => cubit, + act: (cubit) => cubit.refresh(), + expect: () => const [ + ProfileUserState( + isLoading: true, + user: seedUser, + ), + ProfileUserState( + user: updatedUser, + ), + ], + verify: (_) => verify(repository.getUser()).called(1), + ); + + blocTest( + 'emits loading only once when refresh is called twice in progress', + setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)), + build: () => cubit, + act: (cubit) { + cubit.refresh(); + cubit.refresh(); + }, + expect: () => const [ + ProfileUserState( + isLoading: true, + user: seedUser, + ), + ProfileUserState( + user: updatedUser, + ), + ], + verify: (_) => verify(repository.getUser()).called(1), + ); + + blocTest( + 'keeps seed user and stores failure when refresh fails', + setUp: () => when( + repository.getUser(), + ).thenAnswer((_) async => const Failure(ProfileRequestFailure('error_message'))), + build: () => cubit, + act: (cubit) => cubit.refresh(), + expect: () => const [ + ProfileUserState( + isLoading: true, + user: seedUser, + ), + ProfileUserState( + user: seedUser, + failure: ProfileRequestFailure('error_message'), + ), + ], + verify: (_) => verify(repository.getUser()).called(1), + ); + + blocTest( + 'replaceUser updates current user and clears failure', + build: () => cubit, + seed: () => const ProfileUserState( + user: seedUser, + failure: ProfileRequestFailure('error_message'), + ), + act: (cubit) => cubit.replaceUser(updatedUser), + expect: () => const [ + ProfileUserState( + user: updatedUser, + ), + ], + ); + }); +} diff --git a/test/features/profile/presentation/cubits/update_profile_cubit_test.dart b/test/features/profile/presentation/cubits/update_profile_cubit_test.dart new file mode 100644 index 00000000..2ad43271 --- /dev/null +++ b/test/features/profile/presentation/cubits/update_profile_cubit_test.dart @@ -0,0 +1,136 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/update_profile_cubit.dart'; + +import '../../support/profile_dto_fixtures.dart'; +import 'update_profile_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileRepository repository; + late UpdateProfileCubit cubit; + + const currentUser = User( + id: testProfileUserId, + name: testProfileUserName, + email: testProfileUserEmail, + avatar: testProfileUserAvatar, + ); + const updatedUser = User( + id: testProfileUserId, + name: 'test', + email: 'test@mail.com', + avatar: 'new-avatar.jpg', + ); + const failure = ProfileRequestFailure('error_message'); + + setUp(() { + repository = MockProfileRepository(); + cubit = UpdateProfileCubit(repository); + provideDummy>(const Success(updatedUser)); + }); + + group('UpdateProfileCubit', () { + blocTest( + 'emits inProgress and succeed(user) when update succeeds', + setUp: () => when( + repository.updateUser( + currentUser: anyNamed('currentUser'), + name: anyNamed('name'), + email: anyNamed('email'), + avatarPath: anyNamed('avatarPath'), + ), + ).thenAnswer((_) async => const Success(updatedUser)), + build: () => cubit, + act: (cubit) => cubit.updateProfile( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + avatarPath: '/tmp/avatar.jpg', + ), + expect: () => const [ + UpdateProfileState.inProgress(), + UpdateProfileState.succeed(updatedUser), + ], + verify: (_) => verify( + repository.updateUser( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + avatarPath: '/tmp/avatar.jpg', + ), + ).called(1), + ); + + blocTest( + 'emits failed(failure) when update fails', + setUp: () => when( + repository.updateUser( + currentUser: anyNamed('currentUser'), + name: anyNamed('name'), + email: anyNamed('email'), + avatarPath: anyNamed('avatarPath'), + ), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + act: (cubit) => cubit.updateProfile( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + ), + expect: () => const [ + UpdateProfileState.inProgress(), + UpdateProfileState.failed(failure), + ], + verify: (_) => verify( + repository.updateUser( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + ), + ).called(1), + ); + + blocTest( + 'emits inProgress only once when updateProfile is called twice', + setUp: () => when( + repository.updateUser( + currentUser: anyNamed('currentUser'), + name: anyNamed('name'), + email: anyNamed('email'), + avatarPath: anyNamed('avatarPath'), + ), + ).thenAnswer((_) async => const Success(updatedUser)), + build: () => cubit, + act: (cubit) { + cubit.updateProfile( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + ); + cubit.updateProfile( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + ); + }, + expect: () => const [ + UpdateProfileState.inProgress(), + UpdateProfileState.succeed(updatedUser), + ], + verify: (_) => verify( + repository.updateUser( + currentUser: currentUser, + name: 'test', + email: 'test@mail.com', + ), + ).called(1), + ); + }); +} diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart new file mode 100644 index 00000000..b607b5a3 --- /dev/null +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -0,0 +1,78 @@ +import 'package:dio/dio.dart'; +import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/data/dto/profile_user_data_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/profile_user_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/profile_user_response_dto.dart'; + +const testProfileUserId = 1; +const testProfileUserName = 'name'; +const testProfileUserEmail = 'tests@mail.com'; +const testProfileUserAvatar = 'avatar.jpg'; +const testProfileUserCreatedAt = '2026-01-01T10:00:00.000000Z'; +const testProfileUserEmailVerified = true; + +/// Test fixture for a shared authenticated [User]. +User createProfileUser({ + int id = testProfileUserId, + String name = testProfileUserName, + String email = testProfileUserEmail, + String? avatar = testProfileUserAvatar, +}) => User( + id: id, + name: name, + email: email, + avatar: avatar, +); + +/// Test fixture for [ProfileUserDto]. +ProfileUserDto createProfileUserDto({ + int id = testProfileUserId, + String name = testProfileUserName, + String email = testProfileUserEmail, + String? avatarUrl = testProfileUserAvatar, + String createdAt = testProfileUserCreatedAt, + bool emailVerified = testProfileUserEmailVerified, +}) => ProfileUserDto( + id: id, + name: name, + email: email, + avatarUrl: avatarUrl, + createdAt: createdAt, + emailVerified: emailVerified, +); + +/// Test fixture for [ProfileUserResponseDto]. +ProfileUserResponseDto createProfileUserResponseDto({ + ProfileUserDto? user, +}) => ProfileUserResponseDto( + data: ProfileUserDataDto( + user: user ?? createProfileUserDto(), + ), +); + +/// Test fixture for Dio bad response exception. +DioException createProfileDioBadResponseException({ + required String path, + required int statusCode, + required String code, + String message = 'error_message', + Map>? errors, +}) { + final requestOptions = RequestOptions(path: path); + final data = { + 'code': code, + 'message': message, + }; + if (errors != null) { + data['errors'] = errors; + } + return DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response>( + requestOptions: requestOptions, + statusCode: statusCode, + data: data, + ), + ); +} From bc38058bf2240f1df82c6d6d3523e812e88a1d89 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sun, 29 Mar 2026 17:50:48 +0700 Subject: [PATCH 02/13] fix(profile): polish edit profile avatar preview and upload label --- lib/core/constants/app_strings.dart | 1 + .../widgets/edit_profile_dialog.dart | 40 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index 55a5723f..b30eb796 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -251,6 +251,7 @@ abstract final class AppStrings { static const profileNewPasswordLabel = 'Новый пароль'; static const profilePasswordConfirmationLabel = 'Подтверждение пароля'; static const profileUploadFileLabel = 'Загрузить файл:'; + static const profileUploadFormat = 'формат'; static const profileUploadFormatPlaceholder = 'jpg формат'; static const profileEditEmailLabel = 'Введите email'; static const profileEditNameLabel = 'Введите имя'; diff --git a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart index 40ae96a1..0e08e742 100644 --- a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart +++ b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart @@ -118,7 +118,7 @@ class _EditProfileDialogState extends State { } final extension = fileName.substring(dotIndex + 1).toLowerCase(); - return '$extension формат'; + return '$extension ${AppStrings.profileUploadFormat}'; } @override @@ -245,7 +245,7 @@ final class _AvatarPreview extends StatelessWidget { if (localAvatarPath != null && localAvatarPath.isNotEmpty) { return Image.file( File(localAvatarPath), - height: 320, + height: 296, width: double.infinity, fit: BoxFit.cover, ); @@ -266,22 +266,26 @@ final class _AvatarPickButton extends StatelessWidget { Widget build(BuildContext context) { final colorTheme = AppColorTheme.of(context); final textTheme = AppTextTheme.of(context); - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(12), - child: Material( - color: Colors.transparent, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: onPressed == null ? colorTheme.disabled : colorTheme.primary, - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - '+', - textAlign: TextAlign.center, - style: textTheme.button.copyWith(color: colorTheme.onPrimary), + return Semantics( + button: true, + label: AppStrings.profileUploadFileLabel, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: onPressed == null ? colorTheme.disabled : colorTheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + '+', + textAlign: TextAlign.center, + style: textTheme.button.copyWith(color: colorTheme.onPrimary), + ), ), ), ), From 018a6e68227112abba6697e3105303526ec18d41 Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:45:09 +0700 Subject: [PATCH 03/13] feat(profile): implement authenticated profile statistics section (#53) * feat(profile): add statistics api contract * feat(profile): extend profile snapshot for statistics history * feat(profile): add statistics domain models and contracts * feat(profile): implement statistics repositories * test(profile): add statistics repository and mapper coverage * feat(profile): add statistics cubit and state * test(profile): add statistics cubit coverage * feat(profile): wire statistics bootstrap state * feat(profile): add profile statistics ui * test(profile): add profile page widget coverage * docs(changelog): document profile statistics section * refactor(profile): regroup statistics dto contracts * fix(profile): improve statistics frequency mapping and bootstrap flow * feat(profile): polish statistics widgets and layout * fix(ui): add compact option buttons and flexible profile overlays * test(profile): align statistics fixtures and coverage * test(profile): remove obsolete profile page widget coverage * docs: update CHANGELOG.md * fix(profile-stats): show stats error state on failed refresh * fix(profile): reuse cached trend stats on mode switc * test(profile): cover statistics cubit failure states --- CHANGELOG.md | 11 + lib/core/constants/app_strings.dart | 29 + lib/core/di/di.dart | 12 + lib/core/network/api_paths.dart | 18 + .../dto/active_profile_subscription_dto.dart | 56 + .../dto/profile_test_history_item_dto.dart | 72 + .../data/dto/profile_user_data_dto.dart | 15 + .../data/dto/profile_user_response_dto.dart | 4 +- .../dto/profile_workout_history_item_dto.dart | 68 + .../dto/stats/frequency_response_dto.dart | 178 +++ .../stats/profile_exercises_response_dto.dart | 47 + .../stats/profile_workouts_response_dto.dart | 57 + .../data/dto/stats/trend_response_dto.dart | 201 +++ .../data/dto/stats/volume_response_dto.dart | 185 +++ .../profile_history_snapshot_mapper.dart | 56 + .../mappers/profile_statistics_mapper.dart | 138 ++ .../remote/profile_statistics_api_client.dart | 49 + .../repositories/profile_repository_impl.dart | 28 + .../profile_statistics_repository_impl.dart | 124 ++ .../profile_statistics/frequency_period.dart | 31 + .../frequency_statistics_data.dart | 78 ++ .../profile_exercise_option.dart | 23 + .../profile_history_tab.dart | 11 + .../profile_statistics_mode.dart | 11 + .../profile_workout_option.dart | 23 + .../trend_statistics_data.dart | 83 ++ .../volume_statistics_data.dart | 110 ++ .../profile_stats_history_snapshot.dart | 97 ++ .../repositories/profile_repository.dart | 4 + .../profile_statistics_repository.dart | 34 + .../cubits/profile_statistics_cubit.dart | 323 +++++ .../cubits/profile_statistics_state.dart | 23 + .../cubits/profile_user_cubit.dart | 9 + .../cubits/profile_user_state.dart | 1 + .../presentation/pages/profile_page.dart | 88 +- .../pages/profile_page_builder.dart | 21 +- .../widgets/profile_dialog_shell.dart | 13 +- .../profile_statistics_trend_chart.dart | 126 ++ .../widgets/stats/profile_history_dialog.dart | 327 +++++ .../stats/profile_statistics_bar_chart.dart | 287 ++++ .../widgets/stats/stats_section_widget.dart | 1194 +++++++++++++++++ lib/uikit/buttons/button_size.dart | 8 + lib/uikit/buttons/option_button.dart | 24 +- lib/uikit/menus/app_selection_dropdown.dart | 21 +- .../profile_statistics_mapper_test.dart | 76 ++ .../profile_repository_impl_test.dart | 117 ++ ...ofile_statistics_repository_impl_test.dart | 331 +++++ .../cubits/profile_statistics_cubit_test.dart | 437 ++++++ .../cubits/profile_user_cubit_test.dart | 66 +- .../profile/support/profile_dto_fixtures.dart | 128 ++ .../profile_statistics_dto_fixtures.dart | 386 ++++++ 51 files changed, 5799 insertions(+), 60 deletions(-) create mode 100644 lib/features/profile/data/dto/active_profile_subscription_dto.dart create mode 100644 lib/features/profile/data/dto/profile_test_history_item_dto.dart create mode 100644 lib/features/profile/data/dto/profile_workout_history_item_dto.dart create mode 100644 lib/features/profile/data/dto/stats/frequency_response_dto.dart create mode 100644 lib/features/profile/data/dto/stats/profile_exercises_response_dto.dart create mode 100644 lib/features/profile/data/dto/stats/profile_workouts_response_dto.dart create mode 100644 lib/features/profile/data/dto/stats/trend_response_dto.dart create mode 100644 lib/features/profile/data/dto/stats/volume_response_dto.dart create mode 100644 lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart create mode 100644 lib/features/profile/data/mappers/profile_statistics_mapper.dart create mode 100644 lib/features/profile/data/remote/profile_statistics_api_client.dart create mode 100644 lib/features/profile/data/repositories/profile_statistics_repository_impl.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/frequency_period.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/profile_history_tab.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/profile_workout_option.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart create mode 100644 lib/features/profile/domain/entities/profile_stats_history_snapshot.dart create mode 100644 lib/features/profile/domain/repositories/profile_statistics_repository.dart create mode 100644 lib/features/profile/presentation/cubits/profile_statistics_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_statistics_state.dart create mode 100644 lib/features/profile/presentation/widgets/profile_statistics_trend_chart.dart create mode 100644 lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart create mode 100644 lib/features/profile/presentation/widgets/stats/profile_statistics_bar_chart.dart create mode 100644 lib/features/profile/presentation/widgets/stats/stats_section_widget.dart create mode 100644 lib/uikit/buttons/button_size.dart create mode 100644 test/features/profile/data/mappers/profile_statistics_mapper_test.dart create mode 100644 test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart create mode 100644 test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart create mode 100644 test/features/profile/support/profile_statistics_dto_fixtures.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 862e2dd5..895e727e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout. +- Profile statistics section for the authenticated `/profile` tab, including dedicated statistics API client/repository, focused `/profile` history snapshot mapping, statistics Cubit/state flow, chart widgets, selectors, history dialog, and widget coverage for the integrated UI. ### Changed @@ -30,12 +31,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - The `/profile` root tab now renders the real user-section screen instead of the previous placeholder, reuses the authenticated session user as an initial seed, and keeps forgot-password routes reachable from the change-password dialog for authenticated users. +- The `/profile` screen now bootstraps statistics and history from the authenticated profile flow: user bootstrap reuses `/api/profile`, charts switch between dedicated volume/frequency/trend endpoints, and the `История` modal reads active subscription plus the latest workout/test from the cached profile snapshot instead of issuing extra requests. +- Profile statistics internals were reorganized into dedicated `profile/data/dto/stats` and `profile/presentation/widgets/stats` folders, while repository/cubit/widget tests were aligned with the new structure and shared fixtures. +- Shared `OptionButton` now supports canonical `large` and `small` size presets, and the profile statistics plus history-tab controls use the compact 42px variant from the mockups. +- Profile dialogs now support per-dialog content padding and optional barrier dismissal, allowing the statistics history modal to match the provided sheet behavior without affecting non-dismissible dialogs. ### 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. +### Fixed + +- Profile frequency statistics now handle weekly payloads without `short_label`, keep dense period charts readable across `week`, `month`, `3 months`, `6 months`, and `year`, and clarify non-daily X-axis scales with short `нед.` / `мес.` labels. +- Profile statistics dropdown menus now size to their content and render above surrounding UI instead of being clipped by the statistics card or overlapping incorrectly with lower screen sections. +- Profile history and statistics copy now matches the latest profile mockups more closely, including `Активность` / `Завершено` labels, the separate `Средняя оценка:` summary label, and shortened cross-year period labels like `25-26`. + ## [0.3.1] - 2026-03-25 ### Added diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index b30eb796..071c3f96 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -262,8 +262,37 @@ abstract final class AppStrings { static const profileUpdateFailed = 'Не удалось обновить профиль. Попробуйте снова'; static const profileChangePasswordFailed = 'Не удалось сменить пароль. Попробуйте снова'; static const profileImagePickFailed = 'Не удалось выбрать изображение. Попробуйте снова'; + static const profileStatsTitle = 'Статистика тренировок пользователя'; + static const profileStatsHistoryButton = 'История'; + static const profileStatsVolumeMode = 'Объём'; + static const profileStatsFrequencyMode = 'Частота'; + static const profileStatsTrendMode = 'Тренд'; + static const profileStatsCategoriesButton = 'Категории'; + static const profileStatsExercisesButton = 'Упражнения'; + static const profileStatsWorkoutsButton = 'Тренировки'; + static const profileStatsVolumeChartTitle = 'Объём (кг)'; + static const profileStatsFrequencyChartTitle = 'Частота тренировок'; + static const profileStatsTrendChartTitle = 'Тренд по упражнениям'; + static const profileStatsLoadFailed = 'Не удалось загрузить статистику'; + static const profileStatsEmpty = 'Пока недостаточно данных для отображения статистики'; + static const profileStatsHistoryTitle = 'История'; + static const profileStatsHistoryCloseButton = 'Закрыть'; + static const profileStatsHistorySubscriptionsTab = 'Подписки'; + static const profileStatsHistoryWorkoutsTab = 'Тренировки'; + static const profileStatsHistoryTestsTab = 'Тесты'; + static const profileStatsHistorySubscriptionEmpty = 'Активная подписка отсутствует'; + static const profileStatsHistoryWorkoutEmpty = 'Тренировки пока не завершены'; + static const profileStatsHistoryTestEmpty = 'Тесты пока не пройдены'; + static const profileStatsHistoryNameLabel = 'Название'; + static const profileStatsHistoryPriceLabel = 'Стоимость'; + static const profileStatsHistoryPeriodLabel = 'Активность'; + static const profileStatsHistoryCompletedLabel = 'Завершено'; static const profileUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + static const profileStatsAverageScoreLabel = 'Средняя оценка:'; + + static String profileStatsAveragePerWeek(String value) => 'В среднем: $value / нед'; + /// Builds the increase-adjustment message for a new absolute weight value. static String workoutExecutionAdjustmentIncrease(String weight) => 'На следующем подходе увеличьте вес до $weight $workoutExecutionWeightHint'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 4c34e725..cc01afe8 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -18,8 +18,11 @@ import '../../features/fitness_start/data/repositories/fitness_start_repository_ import '../../features/fitness_start/domain/repositories/fitness_start_repository.dart'; import '../../features/offline/presentation/cubit/network_cubit.dart'; import '../../features/profile/data/remote/profile_api_client.dart'; +import '../../features/profile/data/remote/profile_statistics_api_client.dart'; import '../../features/profile/data/repositories/profile_repository_impl.dart'; +import '../../features/profile/data/repositories/profile_statistics_repository_impl.dart'; import '../../features/profile/domain/repositories/profile_repository.dart'; +import '../../features/profile/domain/repositories/profile_statistics_repository.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'; @@ -116,12 +119,21 @@ Future setupDI() async { ), ); di.registerLazySingleton(() => ProfileApiClient(di())); + di.registerLazySingleton( + () => ProfileStatisticsApiClient(di()), + ); di.registerLazySingleton( () => ProfileRepositoryImpl( di(), di(), ), ); + di.registerLazySingleton( + () => ProfileStatisticsRepositoryImpl( + di(), + di(), + ), + ); // Fitness Start di.registerLazySingleton(() => FitnessStartApiClient(di())); diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 66f2b35f..eea9fdc2 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -50,6 +50,24 @@ abstract class ApiPaths { /// The endpoint for uploading or deleting the authenticated user avatar. static const String profileAvatar = '$profile/avatar'; + /// The endpoint prefix for profile statistics. + static const String profileStatistics = '$profile/statistics'; + + /// The endpoint for volume statistics. + static const String profileStatisticsVolume = '$profileStatistics/volume'; + + /// The endpoint for trend statistics. + static const String profileStatisticsTrend = '$profileStatistics/trend'; + + /// The endpoint for frequency statistics. + static const String profileStatisticsFrequency = '$profileStatistics/frequency'; + + /// The endpoint for profile statistics exercises selector. + static const String profileStatisticsExercises = '$profileStatistics/exercises'; + + /// The endpoint for profile statistics workouts selector. + static const String profileStatisticsWorkouts = '$profileStatistics/workouts'; + /// The endpoint for all user-parameters references. static const String userParameterReferences = '${apiPrefix}user-parameters/references'; diff --git a/lib/features/profile/data/dto/active_profile_subscription_dto.dart b/lib/features/profile/data/dto/active_profile_subscription_dto.dart new file mode 100644 index 00000000..705736ca --- /dev/null +++ b/lib/features/profile/data/dto/active_profile_subscription_dto.dart @@ -0,0 +1,56 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'active_profile_subscription_dto.g.dart'; + +/// DTO with focused subscriptions payload from `/profile`. +@JsonSerializable(createToJson: false) +class ProfileSubscriptionsDto { + /// Currently active subscription, if any. + final ActiveProfileSubscriptionDto? active; + + /// Creates an instance of [ProfileSubscriptionsDto]. + ProfileSubscriptionsDto({required this.active}); + + /// Creates a [ProfileSubscriptionsDto] from JSON. + factory ProfileSubscriptionsDto.fromJson(Map json) => + _$ProfileSubscriptionsDtoFromJson(json); +} + +/// DTO for the active subscription snapshot returned by `/profile`. +@JsonSerializable(createToJson: false) +class ActiveProfileSubscriptionDto { + /// Subscription identifier. + final int id; + + /// Subscription display name. + final String name; + + /// Subscription monthly or period price. + final String price; + + /// Subscription start date. + @JsonKey(name: 'start_date') + final String startDate; + + /// Subscription end date. + @JsonKey(name: 'end_date') + final String endDate; + + /// Remaining days count. + @JsonKey(name: 'days_left') + final double? daysLeft; + + /// Creates an instance of [ActiveProfileSubscriptionDto]. + ActiveProfileSubscriptionDto({ + required this.id, + required this.name, + required this.price, + required this.startDate, + required this.endDate, + required this.daysLeft, + }); + + /// Creates a [ActiveProfileSubscriptionDto] from JSON. + factory ActiveProfileSubscriptionDto.fromJson(Map json) => + _$ActiveProfileSubscriptionDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/profile_test_history_item_dto.dart b/lib/features/profile/data/dto/profile_test_history_item_dto.dart new file mode 100644 index 00000000..7814db96 --- /dev/null +++ b/lib/features/profile/data/dto/profile_test_history_item_dto.dart @@ -0,0 +1,72 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_test_history_item_dto.g.dart'; + +/// DTO with focused tests payload from `/profile`. +@JsonSerializable(createToJson: false) +class ProfileTestsDto { + /// Test history items. + @JsonKey(defaultValue: []) + final List history; + + /// Creates an instance of [ProfileTestsDto]. + ProfileTestsDto({required this.history}); + + /// Creates a [ProfileTestsDto] from JSON. + factory ProfileTestsDto.fromJson(Map json) => _$ProfileTestsDtoFromJson(json); +} + +/// DTO for the latest test history snapshot returned by `/profile`. +@JsonSerializable(createToJson: false) +class ProfileTestHistoryItemDto { + /// Attempt identifier. + @JsonKey(name: 'attempt_id') + final int attemptId; + + /// Nested testing reference. + final ProfileTestHistoryTestingDto testing; + + /// Raw completion timestamp. + @JsonKey(name: 'completed_at') + final String completedAt; + + /// Completed pulse value. + final int? pulse; + + /// Number of exercises in the testing. + @JsonKey(name: 'exercises_count') + final int? exercisesCount; + + /// Creates an instance of [ProfileTestHistoryItemDto]. + ProfileTestHistoryItemDto({ + required this.attemptId, + required this.testing, + required this.completedAt, + required this.pulse, + required this.exercisesCount, + }); + + /// Creates a [ProfileTestHistoryItemDto] from JSON. + factory ProfileTestHistoryItemDto.fromJson(Map json) => + _$ProfileTestHistoryItemDtoFromJson(json); +} + +/// DTO with testing title info inside profile history. +@JsonSerializable(createToJson: false) +class ProfileTestHistoryTestingDto { + /// Testing identifier. + final int id; + + /// Testing title. + final String title; + + /// Creates an instance of [ProfileTestHistoryTestingDto]. + ProfileTestHistoryTestingDto({ + required this.id, + required this.title, + }); + + /// Creates a [ProfileTestHistoryTestingDto] from JSON. + factory ProfileTestHistoryTestingDto.fromJson(Map json) => + _$ProfileTestHistoryTestingDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/profile_user_data_dto.dart b/lib/features/profile/data/dto/profile_user_data_dto.dart index 973d099c..4637925f 100644 --- a/lib/features/profile/data/dto/profile_user_data_dto.dart +++ b/lib/features/profile/data/dto/profile_user_data_dto.dart @@ -1,6 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; +import 'active_profile_subscription_dto.dart'; import 'profile_user_dto.dart'; +import 'profile_workout_history_item_dto.dart'; +import 'profile_test_history_item_dto.dart'; part 'profile_user_data_dto.g.dart'; @@ -10,9 +13,21 @@ class ProfileUserDataDto { /// Current authenticated user. final ProfileUserDto user; + /// Active subscription snapshot for profile statistics history. + final ProfileSubscriptionsDto? subscriptions; + + /// Workout history snapshot for profile statistics history. + final ProfileWorkoutsDto? workouts; + + /// Test history snapshot for profile statistics history. + final ProfileTestsDto? tests; + /// Creates an instance of [ProfileUserDataDto]. ProfileUserDataDto({ required this.user, + this.subscriptions, + this.workouts, + this.tests, }); /// Creates a [ProfileUserDataDto] from JSON. diff --git a/lib/features/profile/data/dto/profile_user_response_dto.dart b/lib/features/profile/data/dto/profile_user_response_dto.dart index b551d166..82559843 100644 --- a/lib/features/profile/data/dto/profile_user_response_dto.dart +++ b/lib/features/profile/data/dto/profile_user_response_dto.dart @@ -11,9 +11,7 @@ class ProfileUserResponseDto { final ProfileUserDataDto data; /// Creates an instance of [ProfileUserResponseDto]. - ProfileUserResponseDto({ - required this.data, - }); + ProfileUserResponseDto({required this.data}); /// Creates a [ProfileUserResponseDto] from JSON. factory ProfileUserResponseDto.fromJson(Map json) => diff --git a/lib/features/profile/data/dto/profile_workout_history_item_dto.dart b/lib/features/profile/data/dto/profile_workout_history_item_dto.dart new file mode 100644 index 00000000..839ba535 --- /dev/null +++ b/lib/features/profile/data/dto/profile_workout_history_item_dto.dart @@ -0,0 +1,68 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_workout_history_item_dto.g.dart'; + +/// DTO with focused workouts payload from `/profile`. +@JsonSerializable(createToJson: false) +class ProfileWorkoutsDto { + /// Workout history items. + @JsonKey(defaultValue: []) + final List history; + + /// Creates an instance of [ProfileWorkoutsDto]. + ProfileWorkoutsDto({required this.history}); + + /// Creates a [ProfileWorkoutsDto] from JSON. + factory ProfileWorkoutsDto.fromJson(Map json) => + _$ProfileWorkoutsDtoFromJson(json); +} + +/// DTO for the latest workout history snapshot returned by `/profile`. +@JsonSerializable(createToJson: false) +class ProfileWorkoutHistoryItemDto { + /// History item identifier. + final int id; + + /// Nested workout reference. + final ProfileWorkoutHistoryWorkoutDto workout; + + /// Raw completion timestamp. + @JsonKey(name: 'completed_at') + final String completedAt; + + /// Optional duration in minutes. + @JsonKey(name: 'duration_minutes') + final int? durationMinutes; + + /// Creates an instance of [ProfileWorkoutHistoryItemDto]. + ProfileWorkoutHistoryItemDto({ + required this.id, + required this.workout, + required this.completedAt, + required this.durationMinutes, + }); + + /// Creates a [ProfileWorkoutHistoryItemDto] from JSON. + factory ProfileWorkoutHistoryItemDto.fromJson(Map json) => + _$ProfileWorkoutHistoryItemDtoFromJson(json); +} + +/// DTO with workout title info inside profile history. +@JsonSerializable(createToJson: false) +class ProfileWorkoutHistoryWorkoutDto { + /// Workout identifier. + final int id; + + /// Workout title. + final String title; + + /// Creates an instance of [ProfileWorkoutHistoryWorkoutDto]. + ProfileWorkoutHistoryWorkoutDto({ + required this.id, + required this.title, + }); + + /// Creates a [ProfileWorkoutHistoryWorkoutDto] from JSON. + factory ProfileWorkoutHistoryWorkoutDto.fromJson(Map json) => + _$ProfileWorkoutHistoryWorkoutDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/frequency_response_dto.dart b/lib/features/profile/data/dto/stats/frequency_response_dto.dart new file mode 100644 index 00000000..d9b390f7 --- /dev/null +++ b/lib/features/profile/data/dto/stats/frequency_response_dto.dart @@ -0,0 +1,178 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'frequency_response_dto.g.dart'; + +/// DTO response with profile frequency statistics payload. +@JsonSerializable(createToJson: false) +class FrequencyResponseDto { + /// Nested statistics payload. + final FrequencyStatisticsDto data; + + /// Creates an instance of [FrequencyResponseDto]. + FrequencyResponseDto({required this.data}); + + /// Creates a [FrequencyResponseDto] from JSON. + factory FrequencyResponseDto.fromJson(Map json) => + _$FrequencyResponseDtoFromJson(json); +} + +/// DTO for frequency statistics. +@JsonSerializable(createToJson: false) +class FrequencyStatisticsDto { + /// Whether selected frequency period has data. + @JsonKey(name: 'has_data', defaultValue: false) + final bool hasData; + + /// Current period info. + @JsonKey(name: 'period_info') + final FrequencyPeriodInfoDto? periodInfo; + + /// Summary block. + final FrequencySummaryDto? summary; + + /// Chart items. + @JsonKey(defaultValue: []) + final List chart; + + /// Creates an instance of [FrequencyStatisticsDto]. + FrequencyStatisticsDto({ + required this.hasData, + required this.periodInfo, + required this.summary, + required this.chart, + }); + + /// Creates a [FrequencyStatisticsDto] from JSON. + factory FrequencyStatisticsDto.fromJson(Map json) => + _$FrequencyStatisticsDtoFromJson(json); +} + +/// DTO with current frequency period metadata. +@JsonSerializable(createToJson: false) +class FrequencyPeriodInfoDto { + /// Frequency period key. + final String type; + + /// Current offset. + final int offset; + + /// Human-readable label. + final String label; + + /// Chart items count. + @JsonKey(name: 'items_count') + final int? itemsCount; + + /// Creates an instance of [FrequencyPeriodInfoDto]. + FrequencyPeriodInfoDto({ + required this.type, + required this.offset, + required this.label, + required this.itemsCount, + }); + + /// Creates a [FrequencyPeriodInfoDto] from JSON. + factory FrequencyPeriodInfoDto.fromJson(Map json) => + _$FrequencyPeriodInfoDtoFromJson(json); +} + +/// DTO with summary values for frequency statistics. +@JsonSerializable(createToJson: false) +class FrequencySummaryDto { + /// Total completed workouts. + @JsonKey(name: 'total_workouts') + final int? totalWorkouts; + + /// Average workouts per week. + @JsonKey(name: 'average_per_week') + final double? averagePerWeek; + + /// Current streak length. + @JsonKey(name: 'current_streak') + final int? currentStreak; + + /// Longest streak length. + @JsonKey(name: 'longest_streak') + final int? longestStreak; + + /// Weekly goal. + @JsonKey(name: 'weekly_goal') + final int? weeklyGoal; + + /// Creates an instance of [FrequencySummaryDto]. + FrequencySummaryDto({ + required this.totalWorkouts, + required this.averagePerWeek, + required this.currentStreak, + required this.longestStreak, + required this.weeklyGoal, + }); + + /// Creates a [FrequencySummaryDto] from JSON. + factory FrequencySummaryDto.fromJson(Map json) => + _$FrequencySummaryDtoFromJson(json); +} + +/// DTO item for the frequency chart. +@JsonSerializable(createToJson: false) +class FrequencyChartItemDto { + /// Current day position for `week` payloads. + @JsonKey(name: 'day_index') + final int? dayIndex; + + /// Day number for `week` payloads. + @JsonKey(name: 'day_number') + final int? dayNumber; + + /// Current chart position. + @JsonKey(name: 'week_index') + final int? weekIndex; + + /// Week number. + @JsonKey(name: 'week_number') + final int? weekNumber; + + /// Full label. + final String label; + + /// Short label. + @JsonKey(name: 'short_label') + final String? shortLabel; + + /// Formatted date label for `week` payloads. + @JsonKey(name: 'date_formatted') + final String? dateFormatted; + + /// Range start date. + @JsonKey(name: 'start_date') + final String? startDate; + + /// Range end date. + @JsonKey(name: 'end_date') + final String? endDate; + + /// Completed workouts count. + final int count; + + /// Goal for the bar. + final int? goal; + + /// Creates an instance of [FrequencyChartItemDto]. + FrequencyChartItemDto({ + required this.dayIndex, + required this.dayNumber, + required this.weekIndex, + required this.weekNumber, + required this.label, + this.shortLabel, + this.dateFormatted, + required this.startDate, + required this.endDate, + required this.count, + required this.goal, + }); + + /// Creates a [FrequencyChartItemDto] from JSON. + factory FrequencyChartItemDto.fromJson(Map json) => + _$FrequencyChartItemDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/profile_exercises_response_dto.dart b/lib/features/profile/data/dto/stats/profile_exercises_response_dto.dart new file mode 100644 index 00000000..3a5ddfa2 --- /dev/null +++ b/lib/features/profile/data/dto/stats/profile_exercises_response_dto.dart @@ -0,0 +1,47 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_exercises_response_dto.g.dart'; + +/// DTO response with available profile statistics exercises. +@JsonSerializable(createToJson: false) +class ProfileExercisesResponseDto { + /// Exercises selector payload. + final List data; + + /// Creates an instance of [ProfileExercisesResponseDto]. + ProfileExercisesResponseDto({required this.data}); + + /// Creates a [ProfileExercisesResponseDto] from JSON. + factory ProfileExercisesResponseDto.fromJson(Map json) => + _$ProfileExercisesResponseDtoFromJson(json); +} + +/// DTO item for the profile exercises selector. +@JsonSerializable(createToJson: false) +class ProfileExerciseItemDto { + /// Exercise identifier. + final int id; + + /// Exercise title. + final String name; + + /// Last raw usage date. + @JsonKey(name: 'last_used') + final String? lastUsed; + + /// Last formatted usage date. + @JsonKey(name: 'last_used_formatted') + final String? lastUsedFormatted; + + /// Creates an instance of [ProfileExerciseItemDto]. + ProfileExerciseItemDto({ + required this.id, + required this.name, + required this.lastUsed, + required this.lastUsedFormatted, + }); + + /// Creates a [ProfileExerciseItemDto] from JSON. + factory ProfileExerciseItemDto.fromJson(Map json) => + _$ProfileExerciseItemDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/profile_workouts_response_dto.dart b/lib/features/profile/data/dto/stats/profile_workouts_response_dto.dart new file mode 100644 index 00000000..3389a7a0 --- /dev/null +++ b/lib/features/profile/data/dto/stats/profile_workouts_response_dto.dart @@ -0,0 +1,57 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_workouts_response_dto.g.dart'; + +/// DTO response with available profile statistics workouts. +@JsonSerializable(createToJson: false) +class ProfileWorkoutsResponseDto { + /// Workouts selector payload. + final List data; + + /// Creates an instance of [ProfileWorkoutsResponseDto]. + ProfileWorkoutsResponseDto({required this.data}); + + /// Creates a [ProfileWorkoutsResponseDto] from JSON. + factory ProfileWorkoutsResponseDto.fromJson(Map json) => + _$ProfileWorkoutsResponseDtoFromJson(json); +} + +/// DTO item for the profile workouts selector. +@JsonSerializable(createToJson: false) +class ProfileWorkoutItemDto { + /// Profile workout item identifier. + final int id; + + /// Workout catalog identifier. + @JsonKey(name: 'workout_id') + final int workoutId; + + /// Workout title. + final String title; + + /// Raw completed date. + @JsonKey(name: 'completed_at') + final String? completedAt; + + /// Formatted completed date. + @JsonKey(name: 'completed_at_formatted') + final String? completedAtFormatted; + + /// Workout duration in minutes. + @JsonKey(name: 'duration_minutes') + final int? durationMinutes; + + /// Creates an instance of [ProfileWorkoutItemDto]. + ProfileWorkoutItemDto({ + required this.id, + required this.workoutId, + required this.title, + required this.completedAt, + required this.completedAtFormatted, + required this.durationMinutes, + }); + + /// Creates a [ProfileWorkoutItemDto] from JSON. + factory ProfileWorkoutItemDto.fromJson(Map json) => + _$ProfileWorkoutItemDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/trend_response_dto.dart b/lib/features/profile/data/dto/stats/trend_response_dto.dart new file mode 100644 index 00000000..1e33b316 --- /dev/null +++ b/lib/features/profile/data/dto/stats/trend_response_dto.dart @@ -0,0 +1,201 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'trend_response_dto.g.dart'; + +/// DTO response with profile trend statistics payload. +@JsonSerializable(createToJson: false) +class TrendResponseDto { + /// Nested statistics payload. + final TrendStatisticsDto data; + + /// Creates an instance of [TrendResponseDto]. + TrendResponseDto({required this.data}); + + /// Creates a [TrendResponseDto] from JSON. + factory TrendResponseDto.fromJson(Map json) => _$TrendResponseDtoFromJson(json); +} + +/// DTO for trend statistics. +@JsonSerializable(createToJson: false) +class TrendStatisticsDto { + /// Whether the selected trend contains data. + @JsonKey(name: 'has_data', defaultValue: false) + final bool hasData; + + /// Selected workout info. + final TrendWorkoutInfoDto? workout; + + /// Average trend score. + @JsonKey(name: 'average_score') + final double? averageScore; + + /// Average score percent. + @JsonKey(name: 'average_score_percent') + final int? averageScorePercent; + + /// Average score label. + @JsonKey(name: 'average_score_label') + final String? averageScoreLabel; + + /// Trend chart items. + @JsonKey(defaultValue: []) + final List chart; + + /// Available workouts included in the response payload. + @JsonKey(name: 'available_workouts', defaultValue: []) + final List availableWorkouts; + + /// Creates an instance of [TrendStatisticsDto]. + TrendStatisticsDto({ + required this.hasData, + required this.workout, + required this.averageScore, + required this.averageScorePercent, + required this.averageScoreLabel, + required this.chart, + required this.availableWorkouts, + }); + + /// Creates a [TrendStatisticsDto] from JSON. + factory TrendStatisticsDto.fromJson(Map json) => + _$TrendStatisticsDtoFromJson(json); +} + +/// DTO with selected workout info inside trend statistics. +@JsonSerializable(createToJson: false) +class TrendWorkoutInfoDto { + /// Trend item identifier. + final int id; + + /// Workout catalog identifier. + @JsonKey(name: 'workout_id') + final int workoutId; + + /// Workout title. + final String title; + + /// Raw completed date. + @JsonKey(name: 'completed_at') + final String? completedAt; + + /// Formatted completed date. + @JsonKey(name: 'completed_at_formatted') + final String? completedAtFormatted; + + /// Workout duration in minutes. + @JsonKey(name: 'duration_minutes') + final int? durationMinutes; + + /// Creates an instance of [TrendWorkoutInfoDto]. + TrendWorkoutInfoDto({ + required this.id, + required this.workoutId, + required this.title, + required this.completedAt, + required this.completedAtFormatted, + required this.durationMinutes, + }); + + /// Creates a [TrendWorkoutInfoDto] from JSON. + factory TrendWorkoutInfoDto.fromJson(Map json) => + _$TrendWorkoutInfoDtoFromJson(json); +} + +/// DTO item for the trend chart. +@JsonSerializable(createToJson: false) +class TrendChartItemDto { + /// Exercise number inside the workout. + @JsonKey(name: 'exercise_number') + final int? exerciseNumber; + + /// Exercise identifier. + @JsonKey(name: 'exercise_id') + final int? exerciseId; + + /// Exercise title. + @JsonKey(name: 'exercise_name') + final String exerciseName; + + /// User reaction value. + final String? reaction; + + /// Raw score value. + final int? score; + + /// Score percent. + @JsonKey(name: 'score_percent') + final int? scorePercent; + + /// Score label. + @JsonKey(name: 'score_label') + final String? scoreLabel; + + /// Used weight. + @JsonKey(name: 'weight_used') + final String? weightUsed; + + /// Completed sets count. + @JsonKey(name: 'sets_completed') + final int? setsCompleted; + + /// Completed reps count. + @JsonKey(name: 'reps_completed') + final int? repsCompleted; + + /// Planned sets count. + @JsonKey(name: 'sets_planned') + final int? setsPlanned; + + /// Planned reps count. + @JsonKey(name: 'reps_planned') + final int? repsPlanned; + + /// Creates an instance of [TrendChartItemDto]. + TrendChartItemDto({ + required this.exerciseNumber, + required this.exerciseId, + required this.exerciseName, + required this.reaction, + required this.score, + required this.scorePercent, + required this.scoreLabel, + required this.weightUsed, + required this.setsCompleted, + required this.repsCompleted, + required this.setsPlanned, + required this.repsPlanned, + }); + + /// Creates a [TrendChartItemDto] from JSON. + factory TrendChartItemDto.fromJson(Map json) => + _$TrendChartItemDtoFromJson(json); +} + +/// DTO item describing a selectable workout inside trend statistics. +@JsonSerializable(createToJson: false) +class AvailableWorkoutDto { + /// Trend item identifier. + final int id; + + /// Workout title. + final String title; + + /// Formatted workout date. + final String? date; + + /// Whether this workout is selected in the backend payload. + @JsonKey(name: 'is_current', defaultValue: false) + final bool isCurrent; + + /// Creates an instance of [AvailableWorkoutDto]. + AvailableWorkoutDto({ + required this.id, + required this.title, + required this.date, + required this.isCurrent, + }); + + /// Creates a [AvailableWorkoutDto] from JSON. + factory AvailableWorkoutDto.fromJson(Map json) => + _$AvailableWorkoutDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/volume_response_dto.dart b/lib/features/profile/data/dto/stats/volume_response_dto.dart new file mode 100644 index 00000000..cc0f0643 --- /dev/null +++ b/lib/features/profile/data/dto/stats/volume_response_dto.dart @@ -0,0 +1,185 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'volume_response_dto.g.dart'; + +/// DTO response with profile volume statistics payload. +@JsonSerializable(createToJson: false) +class VolumeResponseDto { + /// Nested statistics payload. + final VolumeStatisticsDto data; + + /// Creates an instance of [VolumeResponseDto]. + VolumeResponseDto({required this.data}); + + /// Creates a [VolumeResponseDto] from JSON. + factory VolumeResponseDto.fromJson(Map json) => + _$VolumeResponseDtoFromJson(json); +} + +/// DTO for volume statistics. +@JsonSerializable(createToJson: false) +class VolumeStatisticsDto { + /// Whether the selected period has chart data. + @JsonKey(name: 'has_data', defaultValue: false) + final bool hasData; + + /// Selected exercise info. + final ProfileExerciseInfoDto? exercise; + + /// Average score value. + @JsonKey(name: 'average_score') + final double? averageScore; + + /// Average score percent. + @JsonKey(name: 'average_score_percent') + final int? averageScorePercent; + + /// Average score label. + @JsonKey(name: 'average_score_label') + final String? averageScoreLabel; + + /// Current period metadata. + final VolumePeriodDto? period; + + /// Summary payload. + final VolumeSummaryDto? summary; + + /// Chart payload. + @JsonKey(defaultValue: []) + final List chart; + + /// Creates an instance of [VolumeStatisticsDto]. + VolumeStatisticsDto({ + required this.hasData, + required this.exercise, + required this.averageScore, + required this.averageScorePercent, + required this.averageScoreLabel, + required this.period, + required this.summary, + required this.chart, + }); + + /// Creates a [VolumeStatisticsDto] from JSON. + factory VolumeStatisticsDto.fromJson(Map json) => + _$VolumeStatisticsDtoFromJson(json); +} + +/// DTO with selected exercise info inside volume statistics. +@JsonSerializable(createToJson: false) +class ProfileExerciseInfoDto { + /// Exercise identifier. + final int id; + + /// Exercise title. + final String title; + + /// Exercise muscle group. + @JsonKey(name: 'muscle_group') + final String? muscleGroup; + + /// Creates an instance of [ProfileExerciseInfoDto]. + ProfileExerciseInfoDto({ + required this.id, + required this.title, + required this.muscleGroup, + }); + + /// Creates a [ProfileExerciseInfoDto] from JSON. + factory ProfileExerciseInfoDto.fromJson(Map json) => + _$ProfileExerciseInfoDtoFromJson(json); +} + +/// DTO with volume period metadata. +@JsonSerializable(createToJson: false) +class VolumePeriodDto { + /// Period start date. + final String start; + + /// Period end date. + final String end; + + /// Period label. + final String label; + + /// Current week number. + @JsonKey(name: 'week_number') + final int? weekNumber; + + /// Current week offset. + @JsonKey(name: 'week_offset') + final int? weekOffset; + + /// Whether previous period navigation is allowed. + @JsonKey(name: 'can_go_previous', defaultValue: false) + final bool canGoPrevious; + + /// Whether next period navigation is allowed. + @JsonKey(name: 'can_go_next', defaultValue: false) + final bool canGoNext; + + /// Creates an instance of [VolumePeriodDto]. + VolumePeriodDto({ + required this.start, + required this.end, + required this.label, + required this.weekNumber, + required this.weekOffset, + required this.canGoPrevious, + required this.canGoNext, + }); + + /// Creates a [VolumePeriodDto] from JSON. + factory VolumePeriodDto.fromJson(Map json) => _$VolumePeriodDtoFromJson(json); +} + +/// DTO with summary values for volume statistics. +@JsonSerializable(createToJson: false) +class VolumeSummaryDto { + /// Total volume for the selected period. + @JsonKey(name: 'total_volume') + final double? totalVolume; + + /// Completed workouts count. + @JsonKey(name: 'workout_count') + final int? workoutCount; + + /// Average volume per workout. + @JsonKey(name: 'average_volume_per_workout') + final double? averageVolumePerWorkout; + + /// Creates an instance of [VolumeSummaryDto]. + VolumeSummaryDto({ + required this.totalVolume, + required this.workoutCount, + required this.averageVolumePerWorkout, + }); + + /// Creates a [VolumeSummaryDto] from JSON. + factory VolumeSummaryDto.fromJson(Map json) => _$VolumeSummaryDtoFromJson(json); +} + +/// DTO item for the volume chart. +@JsonSerializable(createToJson: false) +class VolumeChartItemDto { + /// Axis label. + final String name; + + /// Total volume for the point. + @JsonKey(name: 'total_volume') + final double totalVolume; + + /// Chart point date. + final String? date; + + /// Creates an instance of [VolumeChartItemDto]. + VolumeChartItemDto({ + required this.name, + required this.totalVolume, + required this.date, + }); + + /// Creates a [VolumeChartItemDto] from JSON. + factory VolumeChartItemDto.fromJson(Map json) => + _$VolumeChartItemDtoFromJson(json); +} diff --git a/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart b/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart new file mode 100644 index 00000000..aca2241b --- /dev/null +++ b/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart @@ -0,0 +1,56 @@ +import '../../domain/entities/profile_stats_history_snapshot.dart'; +import '../dto/active_profile_subscription_dto.dart'; +import '../dto/profile_test_history_item_dto.dart'; +import '../dto/profile_user_data_dto.dart'; +import '../dto/profile_workout_history_item_dto.dart'; + +/// Maps aggregate `/profile` DTO subset to history snapshot entities. +extension ProfileHistorySnapshotMapper on ProfileUserDataDto { + /// Returns a focused history snapshot for profile statistics UI. + ProfileStatsHistorySnapshot toStatsHistorySnapshot() { + final sortedWorkouts = [ + ...?workouts?.history, + ]..sort((left, right) => _parseDate(right.completedAt).compareTo(_parseDate(left.completedAt))); + + final sortedTests = [ + ...?tests?.history, + ]..sort((left, right) => _parseDate(right.completedAt).compareTo(_parseDate(left.completedAt))); + + return ProfileStatsHistorySnapshot( + activeSubscription: subscriptions?.active?.toEntity(), + latestWorkout: sortedWorkouts.isEmpty ? null : sortedWorkouts.first.toEntity(), + latestTest: sortedTests.isEmpty ? null : sortedTests.first.toEntity(), + ); + } +} + +extension on ActiveProfileSubscriptionDto { + ProfileActiveSubscriptionSnapshot toEntity() => ProfileActiveSubscriptionSnapshot( + id: id, + name: name, + price: price, + startDate: startDate, + endDate: endDate, + ); +} + +extension on ProfileWorkoutHistoryItemDto { + ProfileLatestWorkoutSnapshot toEntity() => ProfileLatestWorkoutSnapshot( + id: id, + title: workout.title, + completedAt: completedAt, + ); +} + +extension on ProfileTestHistoryItemDto { + ProfileLatestTestSnapshot toEntity() => ProfileLatestTestSnapshot( + attemptId: attemptId, + title: testing.title, + completedAt: completedAt, + ); +} + +DateTime _parseDate(String rawValue) { + final normalizedValue = rawValue.contains(' ') ? rawValue.replaceFirst(' ', 'T') : rawValue; + return DateTime.tryParse(normalizedValue) ?? DateTime.fromMillisecondsSinceEpoch(0); +} diff --git a/lib/features/profile/data/mappers/profile_statistics_mapper.dart b/lib/features/profile/data/mappers/profile_statistics_mapper.dart new file mode 100644 index 00000000..632e67cf --- /dev/null +++ b/lib/features/profile/data/mappers/profile_statistics_mapper.dart @@ -0,0 +1,138 @@ +import '../../domain/entities/profile_statistics/frequency_period.dart'; +import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; +import '../../domain/entities/profile_statistics/profile_workout_option.dart'; +import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; +import '../../domain/entities/profile_statistics/volume_statistics_data.dart'; +import '../dto/stats/frequency_response_dto.dart'; +import '../dto/stats/profile_exercises_response_dto.dart'; +import '../dto/stats/profile_workouts_response_dto.dart'; +import '../dto/stats/trend_response_dto.dart'; +import '../dto/stats/volume_response_dto.dart'; + +/// Maps profile statistics DTOs into domain entities. +extension VolumeStatisticsMapper on VolumeStatisticsDto { + /// Converts volume statistics DTO into [VolumeStatisticsData]. + VolumeStatisticsData toEntity() => VolumeStatisticsData( + hasData: hasData, + exerciseId: exercise?.id, + title: exercise?.title ?? '', + averageScorePercent: averageScorePercent ?? 0, + averageScoreLabel: averageScoreLabel ?? '', + period: (period ?? _FallbackVolumePeriodDto()).toEntity(), + chart: chart.map((item) => item.toEntity()).toList(growable: false), + ); +} + +extension on VolumePeriodDto { + VolumePeriodData toEntity() => VolumePeriodData( + start: start, + end: end, + label: label, + weekOffset: weekOffset ?? 0, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + ); +} + +extension on VolumeChartItemDto { + VolumeChartBarData toEntity() => VolumeChartBarData( + label: name, + value: totalVolume, + date: date, + ); +} + +/// Maps trend statistics DTO into domain data. +extension TrendStatisticsMapper on TrendStatisticsDto { + /// Converts trend statistics DTO into [TrendStatisticsData]. + TrendStatisticsData toEntity() => TrendStatisticsData( + hasData: hasData, + workoutId: workout?.id, + title: workout?.title ?? '', + completedAtFormatted: workout?.completedAtFormatted, + averageScorePercent: averageScorePercent ?? 0, + averageScoreLabel: averageScoreLabel ?? '', + exercises: chart.map((item) => item.toEntity()).toList(growable: false), + ); +} + +extension on TrendChartItemDto { + TrendExerciseData toEntity() => TrendExerciseData( + exerciseName: exerciseName, + scorePercent: scorePercent ?? 0, + scoreLabel: scoreLabel, + reaction: reaction, + weightUsed: weightUsed, + ); +} + +/// Maps frequency statistics DTO into domain data. +extension FrequencyStatisticsMapper on FrequencyStatisticsDto { + /// Converts frequency statistics DTO into [FrequencyStatisticsData]. + FrequencyStatisticsData toEntity({ + required FrequencyPeriod fallbackPeriod, + required int fallbackOffset, + }) { + final resolvedPeriod = periodInfo == null + ? fallbackPeriod + : FrequencyPeriod.fromRequestValue(periodInfo!.type); + + return FrequencyStatisticsData( + hasData: hasData, + period: resolvedPeriod, + offset: periodInfo?.offset ?? fallbackOffset, + label: periodInfo?.label ?? '', + averagePerWeek: summary?.averagePerWeek ?? 0, + chart: chart.map((item) => item.toEntity()).toList(growable: false), + ); + } +} + +extension on FrequencyChartItemDto { + FrequencyChartBarData toEntity() => FrequencyChartBarData( + label: label, + shortLabel: shortLabel?.isNotEmpty == true ? shortLabel! : label, + startDate: startDate, + endDate: endDate, + count: count, + goal: goal ?? 0, + ); +} + +/// Maps profile exercise selector DTO list into domain options. +extension ProfileExerciseOptionMapper on List { + /// Converts selector DTO list into exercise options. + List toEntity() => map( + (item) => ProfileExerciseOption( + id: item.id, + name: item.name, + lastUsedFormatted: item.lastUsedFormatted, + ), + ).toList(growable: false); +} + +/// Maps profile workout selector DTO list into domain options. +extension ProfileWorkoutOptionMapper on List { + /// Converts selector DTO list into workout options. + List toEntity() => map( + (item) => ProfileWorkoutOption( + id: item.id, + title: item.title, + completedAtFormatted: item.completedAtFormatted, + ), + ).toList(growable: false); +} + +final class _FallbackVolumePeriodDto extends VolumePeriodDto { + _FallbackVolumePeriodDto() + : super( + start: '', + end: '', + label: '', + weekNumber: 0, + weekOffset: 0, + canGoPrevious: false, + canGoNext: false, + ); +} diff --git a/lib/features/profile/data/remote/profile_statistics_api_client.dart b/lib/features/profile/data/remote/profile_statistics_api_client.dart new file mode 100644 index 00000000..940a1d73 --- /dev/null +++ b/lib/features/profile/data/remote/profile_statistics_api_client.dart @@ -0,0 +1,49 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/stats/frequency_response_dto.dart'; +import '../dto/stats/profile_exercises_response_dto.dart'; +import '../dto/stats/profile_workouts_response_dto.dart'; +import '../dto/stats/trend_response_dto.dart'; +import '../dto/stats/volume_response_dto.dart'; + +part 'profile_statistics_api_client.g.dart'; + +/// Retrofit API client for authenticated profile statistics requests. +@RestApi() +abstract class ProfileStatisticsApiClient { + /// Creates an instance of [ProfileStatisticsApiClient]. + factory ProfileStatisticsApiClient( + Dio dio, { + String? baseUrl, + }) = _ProfileStatisticsApiClient; + + /// Returns volume statistics for the authenticated profile. + @GET(ApiPaths.profileStatisticsVolume) + Future getVolume({ + @Query('exercise_id') int? exerciseId, + @Query('week_offset') int? weekOffset, + }); + + /// Returns trend statistics for the authenticated profile. + @GET(ApiPaths.profileStatisticsTrend) + Future getTrend({ + @Query('workout_id') int? workoutId, + }); + + /// Returns frequency statistics for the authenticated profile. + @GET(ApiPaths.profileStatisticsFrequency) + Future getFrequency({ + @Query('period') String? period, + @Query('offset') int? offset, + }); + + /// Returns available exercises for the profile statistics selector. + @GET(ApiPaths.profileStatisticsExercises) + Future getExercises(); + + /// Returns available workouts for the profile statistics selector. + @GET(ApiPaths.profileStatisticsWorkouts) + Future getWorkouts(); +} diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index a96fddf1..cd746fa5 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -7,10 +7,12 @@ import '../../../../core/network/mappers/dio_exception_mapper.dart'; import '../../../../core/result/result.dart'; import '../../../../core/utils/logger/app_logger.dart'; import '../../../auth/domain/entities/user.dart'; +import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; import '../dto/change_password_request_dto.dart'; import '../dto/update_profile_request_dto.dart'; import '../mappers/profile_failure_mapper.dart'; +import '../mappers/profile_history_snapshot_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -18,6 +20,7 @@ import '../remote/profile_api_client.dart'; final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; + ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; /// Creates an instance of [ProfileRepositoryImpl]. ProfileRepositoryImpl(this._logger, this._apiClient); @@ -26,6 +29,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { Future> getUser() async { try { final response = await _apiClient.getProfile(); + _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); return Result.success(response.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -38,6 +42,29 @@ final class ProfileRepositoryImpl implements ProfileRepository { } } + @override + Future> getStatsHistorySnapshot() async { + final cachedStatsHistorySnapshot = _cachedStatsHistorySnapshot; + if (cachedStatsHistorySnapshot != null) { + return Result.success(cachedStatsHistorySnapshot); + } + + try { + final response = await _apiClient.getProfile(); + final snapshot = response.data.toStatsHistorySnapshot(); + _cachedStatsHistorySnapshot = snapshot; + return Result.success(snapshot); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetStatsHistorySnapshot failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + @override Future> updateUser({ required User currentUser, @@ -74,6 +101,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { } final refreshedResponse = await _apiClient.getProfile(); + _cachedStatsHistorySnapshot = refreshedResponse.data.toStatsHistorySnapshot(); return Result.success(refreshedResponse.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); diff --git a/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart b/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart new file mode 100644 index 00000000..91a6d573 --- /dev/null +++ b/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart @@ -0,0 +1,124 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/logger/app_logger.dart'; +import '../../domain/entities/profile_statistics/frequency_period.dart'; +import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; +import '../../domain/entities/profile_statistics/profile_workout_option.dart'; +import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; +import '../../domain/entities/profile_statistics/volume_statistics_data.dart'; +import '../../domain/repositories/profile_statistics_repository.dart'; +import '../mappers/profile_failure_mapper.dart'; +import '../mappers/profile_statistics_mapper.dart'; +import '../remote/profile_statistics_api_client.dart'; + +/// Implementation of [ProfileStatisticsRepository]. +final class ProfileStatisticsRepositoryImpl implements ProfileStatisticsRepository { + final AppLogger _logger; + final ProfileStatisticsApiClient _apiClient; + + /// Creates an instance of [ProfileStatisticsRepositoryImpl]. + ProfileStatisticsRepositoryImpl(this._logger, this._apiClient); + + @override + Future> getVolume({ + int? exerciseId, + int? weekOffset, + }) async { + try { + final response = await _apiClient.getVolume( + exerciseId: exerciseId, + weekOffset: weekOffset, + ); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetVolume failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> getTrend({ + int? workoutId, + }) async { + try { + final response = await _apiClient.getTrend(workoutId: workoutId); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetTrend failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> getFrequency({ + required FrequencyPeriod period, + required int offset, + }) async { + try { + final response = await _apiClient.getFrequency( + period: period.requestValue, + offset: offset, + ); + return Result.success( + response.data.toEntity( + fallbackPeriod: period, + fallbackOffset: offset, + ), + ); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetFrequency failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future, ProfileFailure>> getExercises() async { + try { + final response = await _apiClient.getExercises(); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetExercises failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future, ProfileFailure>> getWorkouts() async { + try { + final response = await _apiClient.getWorkouts(); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetWorkouts failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } +} diff --git a/lib/features/profile/domain/entities/profile_statistics/frequency_period.dart b/lib/features/profile/domain/entities/profile_statistics/frequency_period.dart new file mode 100644 index 00000000..515c1750 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/frequency_period.dart @@ -0,0 +1,31 @@ +/// Frequency periods supported by the backend. +enum FrequencyPeriod { + /// One week period. + week('week'), + + /// One month period. + month('month'), + + /// Three months period. + threeMonths('3months'), + + /// Six months period. + sixMonths('6months'), + + /// One year period. + year('year') + ; + + /// Request value expected by the backend. + final String requestValue; + + const FrequencyPeriod(this.requestValue); + + /// Maps backend value to [FrequencyPeriod]. + static FrequencyPeriod fromRequestValue(String rawValue) { + return FrequencyPeriod.values.firstWhere( + (item) => item.requestValue == rawValue, + orElse: () => FrequencyPeriod.month, + ); + } +} diff --git a/lib/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart b/lib/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart new file mode 100644 index 00000000..60d2e6c8 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart @@ -0,0 +1,78 @@ +import 'package:equatable/equatable.dart'; + +import 'frequency_period.dart'; + +/// Frequency statistics payload used by the profile statistics UI. +final class FrequencyStatisticsData extends Equatable { + /// Whether the payload contains chart data. + final bool hasData; + + /// Selected frequency period. + final FrequencyPeriod period; + + /// Selected offset. + final int offset; + + /// Human-readable period label. + final String label; + + /// Average workouts per week. + final double averagePerWeek; + + /// Chart bars. + final List chart; + + /// Creates an instance of [FrequencyStatisticsData]. + const FrequencyStatisticsData({ + required this.hasData, + required this.period, + required this.offset, + required this.label, + required this.averagePerWeek, + required this.chart, + }); + + @override + List get props => [ + hasData, + period, + offset, + label, + averagePerWeek, + chart, + ]; +} + +/// Single bar for the frequency chart. +final class FrequencyChartBarData extends Equatable { + /// Axis label. + final String label; + + /// Short axis label. + final String shortLabel; + + /// Period start date used for monthly aggregation in long frequency ranges. + final String? startDate; + + /// Period end date used for monthly aggregation in long frequency ranges. + final String? endDate; + + /// Bar count. + final int count; + + /// Goal reference line. + final int goal; + + /// Creates an instance of [FrequencyChartBarData]. + const FrequencyChartBarData({ + required this.label, + required this.shortLabel, + this.startDate, + this.endDate, + required this.count, + required this.goal, + }); + + @override + List get props => [label, shortLabel, startDate, endDate, count, goal]; +} diff --git a/lib/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart b/lib/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart new file mode 100644 index 00000000..3b1ad761 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +/// Single-select option for the profile statistics exercise selector. +final class ProfileExerciseOption extends Equatable { + /// Exercise identifier. + final int id; + + /// Exercise title. + final String name; + + /// Optional formatted last usage label. + final String? lastUsedFormatted; + + /// Creates an instance of [ProfileExerciseOption]. + const ProfileExerciseOption({ + required this.id, + required this.name, + required this.lastUsedFormatted, + }); + + @override + List get props => [id, name, lastUsedFormatted]; +} diff --git a/lib/features/profile/domain/entities/profile_statistics/profile_history_tab.dart b/lib/features/profile/domain/entities/profile_statistics/profile_history_tab.dart new file mode 100644 index 00000000..9ac4f83e --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/profile_history_tab.dart @@ -0,0 +1,11 @@ +/// Tabs available in the profile statistics history dialog. +enum ProfileHistoryTab { + /// Active subscription snapshot. + subscriptions, + + /// Latest workout snapshot. + workouts, + + /// Latest test snapshot. + tests, +} diff --git a/lib/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart b/lib/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart new file mode 100644 index 00000000..30c78c0e --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart @@ -0,0 +1,11 @@ +/// Available modes for the profile statistics section. +enum ProfileStatisticsMode { + /// Volume chart. + volume, + + /// Frequency chart. + frequency, + + /// Trend view. + trend, +} diff --git a/lib/features/profile/domain/entities/profile_statistics/profile_workout_option.dart b/lib/features/profile/domain/entities/profile_statistics/profile_workout_option.dart new file mode 100644 index 00000000..d345a623 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/profile_workout_option.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +/// Single-select option for the profile statistics workout selector. +final class ProfileWorkoutOption extends Equatable { + /// Workout selector identifier. + final int id; + + /// Workout title. + final String title; + + /// Optional formatted completion date. + final String? completedAtFormatted; + + /// Creates an instance of [ProfileWorkoutOption]. + const ProfileWorkoutOption({ + required this.id, + required this.title, + required this.completedAtFormatted, + }); + + @override + List get props => [id, title, completedAtFormatted]; +} diff --git a/lib/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart b/lib/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart new file mode 100644 index 00000000..c56b11e6 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart @@ -0,0 +1,83 @@ +import 'package:equatable/equatable.dart'; + +/// Trend statistics payload used by the profile statistics UI. +final class TrendStatisticsData extends Equatable { + /// Whether the payload contains chart data. + final bool hasData; + + /// Selected workout identifier. + final int? workoutId; + + /// Selected workout title. + final String title; + + /// Selected workout formatted completion date. + final String? completedAtFormatted; + + /// Average score percent. + final int averageScorePercent; + + /// Average score label. + final String averageScoreLabel; + + /// Trend exercise rows. + final List exercises; + + /// Creates an instance of [TrendStatisticsData]. + const TrendStatisticsData({ + required this.hasData, + required this.workoutId, + required this.title, + required this.completedAtFormatted, + required this.averageScorePercent, + required this.averageScoreLabel, + required this.exercises, + }); + + @override + List get props => [ + hasData, + workoutId, + title, + completedAtFormatted, + averageScorePercent, + averageScoreLabel, + exercises, + ]; +} + +/// Trend row for a single exercise. +final class TrendExerciseData extends Equatable { + /// Exercise title. + final String exerciseName; + + /// Score percent. + final int scorePercent; + + /// Score label. + final String? scoreLabel; + + /// Reaction value. + final String? reaction; + + /// Used weight. + final String? weightUsed; + + /// Creates an instance of [TrendExerciseData]. + const TrendExerciseData({ + required this.exerciseName, + required this.scorePercent, + required this.scoreLabel, + required this.reaction, + required this.weightUsed, + }); + + @override + List get props => [ + exerciseName, + scorePercent, + scoreLabel, + reaction, + weightUsed, + ]; +} diff --git a/lib/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart b/lib/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart new file mode 100644 index 00000000..b55014ba --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart @@ -0,0 +1,110 @@ +import 'package:equatable/equatable.dart'; + +/// Volume statistics payload used by the profile statistics UI. +final class VolumeStatisticsData extends Equatable { + /// Whether the payload contains chart data. + final bool hasData; + + /// Selected exercise identifier. + final int? exerciseId; + + /// Selected exercise title. + final String title; + + /// Average score percent. + final int averageScorePercent; + + /// Average score label. + final String averageScoreLabel; + + /// Current period. + final VolumePeriodData period; + + /// Chart bars. + final List chart; + + /// Creates an instance of [VolumeStatisticsData]. + const VolumeStatisticsData({ + required this.hasData, + required this.exerciseId, + required this.title, + required this.averageScorePercent, + required this.averageScoreLabel, + required this.period, + required this.chart, + }); + + @override + List get props => [ + hasData, + exerciseId, + title, + averageScorePercent, + averageScoreLabel, + period, + chart, + ]; +} + +/// Current volume period metadata. +final class VolumePeriodData extends Equatable { + /// Period start date. + final String start; + + /// Period end date. + final String end; + + /// Human-readable period label. + final String label; + + /// Current week offset. + final int weekOffset; + + /// Whether previous period navigation is allowed. + final bool canGoPrevious; + + /// Whether next period navigation is allowed. + final bool canGoNext; + + /// Creates an instance of [VolumePeriodData]. + const VolumePeriodData({ + required this.start, + required this.end, + required this.label, + required this.weekOffset, + required this.canGoPrevious, + required this.canGoNext, + }); + + @override + List get props => [ + start, + end, + label, + weekOffset, + canGoPrevious, + canGoNext, + ]; +} + +/// Single bar for the volume chart. +final class VolumeChartBarData extends Equatable { + /// Axis label. + final String label; + + /// Bar value. + final double value; + + /// Optional bar date. + final String? date; + + /// Creates an instance of [VolumeChartBarData]. + const VolumeChartBarData({ + required this.label, + required this.value, + required this.date, + }); + + @override + List get props => [label, value, date]; +} diff --git a/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart b/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart new file mode 100644 index 00000000..9a5d95e0 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart @@ -0,0 +1,97 @@ +import 'package:equatable/equatable.dart'; + +/// Focused snapshot used by the profile statistics history dialog. +final class ProfileStatsHistorySnapshot extends Equatable { + /// Currently active subscription. + final ProfileActiveSubscriptionSnapshot? activeSubscription; + + /// Latest completed workout. + final ProfileLatestWorkoutSnapshot? latestWorkout; + + /// Latest completed testing. + final ProfileLatestTestSnapshot? latestTest; + + /// Creates an instance of [ProfileStatsHistorySnapshot]. + const ProfileStatsHistorySnapshot({ + required this.activeSubscription, + required this.latestWorkout, + required this.latestTest, + }); + + @override + List get props => [activeSubscription, latestWorkout, latestTest]; +} + +/// Active subscription content used in the history dialog. +final class ProfileActiveSubscriptionSnapshot extends Equatable { + /// Subscription identifier. + final int id; + + /// Subscription title. + final String name; + + /// Subscription price value. + final String price; + + /// Start date. + final String startDate; + + /// End date. + final String endDate; + + /// Creates an instance of [ProfileActiveSubscriptionSnapshot]. + const ProfileActiveSubscriptionSnapshot({ + required this.id, + required this.name, + required this.price, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [id, name, price, startDate, endDate]; +} + +/// Latest workout content used in the history dialog. +final class ProfileLatestWorkoutSnapshot extends Equatable { + /// Workout identifier. + final int id; + + /// Workout title. + final String title; + + /// Completion timestamp. + final String completedAt; + + /// Creates an instance of [ProfileLatestWorkoutSnapshot]. + const ProfileLatestWorkoutSnapshot({ + required this.id, + required this.title, + required this.completedAt, + }); + + @override + List get props => [id, title, completedAt]; +} + +/// Latest test content used in the history dialog. +final class ProfileLatestTestSnapshot extends Equatable { + /// Test attempt identifier. + final int attemptId; + + /// Testing title. + final String title; + + /// Completion timestamp. + final String completedAt; + + /// Creates an instance of [ProfileLatestTestSnapshot]. + const ProfileLatestTestSnapshot({ + required this.attemptId, + required this.title, + required this.completedAt, + }); + + @override + List get props => [attemptId, title, completedAt]; +} diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index b8f1f18c..8d210d18 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -1,12 +1,16 @@ import '../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; +import '../entities/profile_stats_history_snapshot.dart'; /// Repository interface for authenticated profile operations. abstract interface class ProfileRepository { /// Returns the current authenticated user from the profile payload. Future> getUser(); + /// Returns the current history snapshot for the statistics history modal. + Future> getStatsHistorySnapshot(); + /// Updates the current user profile and returns the canonical refreshed user payload. Future> updateUser({ required User currentUser, diff --git a/lib/features/profile/domain/repositories/profile_statistics_repository.dart b/lib/features/profile/domain/repositories/profile_statistics_repository.dart new file mode 100644 index 00000000..55225329 --- /dev/null +++ b/lib/features/profile/domain/repositories/profile_statistics_repository.dart @@ -0,0 +1,34 @@ +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/profile_statistics/frequency_period.dart'; +import '../entities/profile_statistics/frequency_statistics_data.dart'; +import '../entities/profile_statistics/profile_exercise_option.dart'; +import '../entities/profile_statistics/profile_workout_option.dart'; +import '../entities/profile_statistics/trend_statistics_data.dart'; +import '../entities/profile_statistics/volume_statistics_data.dart'; + +/// Repository interface for profile statistics operations. +abstract interface class ProfileStatisticsRepository { + /// Returns volume statistics for the selected exercise and week offset. + Future> getVolume({ + int? exerciseId, + int? weekOffset, + }); + + /// Returns trend statistics for the selected workout. + Future> getTrend({ + int? workoutId, + }); + + /// Returns frequency statistics for the selected period and offset. + Future> getFrequency({ + required FrequencyPeriod period, + required int offset, + }); + + /// Returns exercise selector options. + Future, ProfileFailure>> getExercises(); + + /// Returns workout selector options. + Future, ProfileFailure>> getWorkouts(); +} diff --git a/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart new file mode 100644 index 00000000..53c68612 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart @@ -0,0 +1,323 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../domain/entities/profile_statistics/frequency_period.dart'; +import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; +import '../../domain/entities/profile_statistics/profile_history_tab.dart'; +import '../../domain/entities/profile_statistics/profile_statistics_mode.dart'; +import '../../domain/entities/profile_statistics/profile_workout_option.dart'; +import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; +import '../../domain/entities/profile_statistics/volume_statistics_data.dart'; +import '../../domain/entities/profile_stats_history_snapshot.dart'; +import '../../domain/repositories/profile_statistics_repository.dart'; + +part 'profile_statistics_cubit.freezed.dart'; +part 'profile_statistics_state.dart'; + +/// Cubit that manages the profile statistics state flow. +final class ProfileStatisticsCubit extends Cubit { + final ProfileStatisticsRepository _repository; + + /// Creates an instance of [ProfileStatisticsCubit]. + ProfileStatisticsCubit(this._repository) : super(const ProfileStatisticsState()); + + /// Loads the initial statistics payload. + Future loadInitial() async { + if (state.isLoading) return; + + emit(state.copyWith(isLoading: true, failure: null)); + + final volumeResult = await _repository.getVolume(); + final exercisesResult = await _repository.getExercises(); + + if (isClosed) return; + + switch (volumeResult) { + case Success(data: final volumeData): + final exerciseOptions = switch (exercisesResult) { + Success(data: final options) => options, + Failure() => const [], + }; + emit( + state.copyWith( + isLoading: false, + mode: ProfileStatisticsMode.volume, + selectedExerciseId: volumeData.exerciseId, + volumeData: volumeData, + exerciseOptions: exerciseOptions, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } + + /// Stores the latest history snapshot provided by the profile bootstrap flow. + void setHistorySnapshot(ProfileStatsHistorySnapshot historySnapshot) { + if (isClosed || state.historySnapshot == historySnapshot) return; + + emit(state.copyWith(historySnapshot: historySnapshot)); + } + + /// Updates the selected history tab. + void selectHistoryTab(ProfileHistoryTab tab) { + if (isClosed || state.selectedHistoryTab == tab) return; + + emit(state.copyWith(selectedHistoryTab: tab)); + } + + /// Switches the visible statistics mode. + Future selectMode(ProfileStatisticsMode mode) async { + if (state.isLoading || state.mode == mode) return; + + emit(state.copyWith(mode: mode, failure: null)); + + switch (mode) { + case ProfileStatisticsMode.volume: + if (state.volumeData != null && state.exerciseOptions.isNotEmpty) return; + await _loadVolume( + exerciseId: state.selectedExerciseId, + weekOffset: state.volumeData?.period.weekOffset ?? 0, + loadExercises: state.exerciseOptions.isEmpty, + ); + case ProfileStatisticsMode.frequency: + if (state.frequencyData != null) return; + await _loadFrequency( + period: state.selectedFrequencyPeriod, + offset: state.selectedFrequencyOffset, + ); + case ProfileStatisticsMode.trend: + if (state.trendData != null && state.workoutOptions.isNotEmpty) return; + await _loadTrend( + workoutId: state.selectedWorkoutId, + loadWorkouts: state.workoutOptions.isEmpty, + ); + } + } + + /// Selects a new volume exercise and refreshes the chart. + Future selectExercise(int exerciseId) async { + if (state.isLoading || state.selectedExerciseId == exerciseId) return; + + await _loadVolume( + exerciseId: exerciseId, + weekOffset: state.volumeData?.period.weekOffset ?? 0, + loadExercises: false, + ); + } + + /// Selects a new trend workout and refreshes the trend payload. + Future selectWorkout(int workoutId) async { + if (state.isLoading || state.selectedWorkoutId == workoutId) return; + + await _loadTrend( + workoutId: workoutId, + loadWorkouts: false, + ); + } + + /// Selects a new frequency period and resets offset to zero. + Future selectFrequencyPeriod(FrequencyPeriod period) async { + if (state.isLoading || state.selectedFrequencyPeriod == period) return; + + await _loadFrequency(period: period, offset: 0); + } + + /// Loads the previous available period for the currently selected mode. + Future loadPreviousPeriod() async { + if (state.isLoading) return; + + switch (state.mode) { + case ProfileStatisticsMode.volume: + final volumeData = state.volumeData; + if (volumeData == null || !volumeData.period.canGoPrevious) return; + await _loadVolume( + exerciseId: state.selectedExerciseId, + weekOffset: volumeData.period.weekOffset + 1, + loadExercises: false, + ); + case ProfileStatisticsMode.frequency: + await _loadFrequency( + period: state.selectedFrequencyPeriod, + offset: state.selectedFrequencyOffset + 1, + ); + case ProfileStatisticsMode.trend: + return; + } + } + + /// Loads the next available period for the currently selected mode. + Future loadNextPeriod() async { + if (state.isLoading) return; + + switch (state.mode) { + case ProfileStatisticsMode.volume: + final volumeData = state.volumeData; + if (volumeData == null || !volumeData.period.canGoNext) return; + await _loadVolume( + exerciseId: state.selectedExerciseId, + weekOffset: volumeData.period.weekOffset - 1, + loadExercises: false, + ); + case ProfileStatisticsMode.frequency: + if (state.selectedFrequencyOffset <= 0) return; + await _loadFrequency( + period: state.selectedFrequencyPeriod, + offset: state.selectedFrequencyOffset - 1, + ); + case ProfileStatisticsMode.trend: + return; + } + } + + /// Reloads the current statistics mode without changing local selections. + Future reload() async { + if (state.isLoading) return; + + switch (state.mode) { + case ProfileStatisticsMode.volume: + await _loadVolume( + exerciseId: state.selectedExerciseId, + weekOffset: state.volumeData?.period.weekOffset ?? 0, + loadExercises: state.exerciseOptions.isEmpty, + ); + case ProfileStatisticsMode.frequency: + await _loadFrequency( + period: state.selectedFrequencyPeriod, + offset: state.selectedFrequencyOffset, + ); + case ProfileStatisticsMode.trend: + await _loadTrend( + workoutId: state.selectedWorkoutId, + loadWorkouts: state.workoutOptions.isEmpty, + ); + } + } + + Future _loadVolume({ + required int? exerciseId, + required int weekOffset, + required bool loadExercises, + }) async { + emit(state.copyWith(isLoading: true, failure: null)); + + final volumeFuture = _repository.getVolume( + exerciseId: exerciseId, + weekOffset: weekOffset, + ); + final exercisesFuture = loadExercises ? _repository.getExercises() : null; + + final volumeResult = await volumeFuture; + final exercisesResult = await exercisesFuture; + if (isClosed) return; + + switch (volumeResult) { + case Success(data: final volumeData): + final nextExerciseOptions = switch (exercisesResult) { + Success(data: final options) => options, + Failure() => state.exerciseOptions, + null => state.exerciseOptions, + }; + emit( + state.copyWith( + isLoading: false, + mode: ProfileStatisticsMode.volume, + selectedExerciseId: volumeData.exerciseId ?? exerciseId, + volumeData: volumeData, + exerciseOptions: nextExerciseOptions, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } + + Future _loadTrend({ + required int? workoutId, + required bool loadWorkouts, + }) async { + emit(state.copyWith(isLoading: true, failure: null)); + + final trendFuture = _repository.getTrend(workoutId: workoutId); + final workoutsFuture = loadWorkouts ? _repository.getWorkouts() : null; + + final trendResult = await trendFuture; + final workoutsResult = await workoutsFuture; + if (isClosed) return; + + switch (trendResult) { + case Success(data: final trendData): + final nextWorkoutOptions = switch (workoutsResult) { + Success(data: final options) => options, + Failure() => state.workoutOptions, + null => state.workoutOptions, + }; + emit( + state.copyWith( + isLoading: false, + mode: ProfileStatisticsMode.trend, + selectedWorkoutId: trendData.workoutId ?? workoutId, + trendData: trendData, + workoutOptions: nextWorkoutOptions, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } + + Future _loadFrequency({ + required FrequencyPeriod period, + required int offset, + }) async { + emit(state.copyWith(isLoading: true, failure: null)); + + final result = await _repository.getFrequency( + period: period, + offset: offset, + ); + if (isClosed) return; + + switch (result) { + case Success(data: final frequencyData): + emit( + state.copyWith( + isLoading: false, + mode: ProfileStatisticsMode.frequency, + selectedFrequencyPeriod: frequencyData.period, + selectedFrequencyOffset: frequencyData.offset, + frequencyData: frequencyData, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } +} diff --git a/lib/features/profile/presentation/cubits/profile_statistics_state.dart b/lib/features/profile/presentation/cubits/profile_statistics_state.dart new file mode 100644 index 00000000..f91370cc --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_statistics_state.dart @@ -0,0 +1,23 @@ +part of 'profile_statistics_cubit.dart'; + +/// State for [ProfileStatisticsCubit]. +@freezed +abstract class ProfileStatisticsState with _$ProfileStatisticsState { + /// Creates an instance of [ProfileStatisticsState]. + const factory ProfileStatisticsState({ + @Default(false) bool isLoading, + @Default(ProfileStatisticsMode.volume) ProfileStatisticsMode mode, + @Default(ProfileHistoryTab.subscriptions) ProfileHistoryTab selectedHistoryTab, + int? selectedExerciseId, + int? selectedWorkoutId, + @Default(FrequencyPeriod.month) FrequencyPeriod selectedFrequencyPeriod, + @Default(0) int selectedFrequencyOffset, + ProfileStatsHistorySnapshot? historySnapshot, + VolumeStatisticsData? volumeData, + FrequencyStatisticsData? frequencyData, + TrendStatisticsData? trendData, + @Default([]) List exerciseOptions, + @Default([]) List workoutOptions, + ProfileFailure? failure, + }) = _ProfileStatisticsState; +} diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart index 745c52fe..ab83ab64 100644 --- a/lib/features/profile/presentation/cubits/profile_user_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart @@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; +import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; part 'profile_user_cubit.freezed.dart'; @@ -35,10 +36,18 @@ final class ProfileUserCubit extends Cubit { switch (result) { case Success(data: final user): + final historyResult = await _repository.getStatsHistorySnapshot(); + if (isClosed) return; + + final historySnapshot = switch (historyResult) { + Success(data: final snapshot) => snapshot, + Failure() => state.historySnapshot, + }; emit( state.copyWith( isLoading: false, user: user, + historySnapshot: historySnapshot, failure: null, ), ); diff --git a/lib/features/profile/presentation/cubits/profile_user_state.dart b/lib/features/profile/presentation/cubits/profile_user_state.dart index a070f8b8..21bbcdee 100644 --- a/lib/features/profile/presentation/cubits/profile_user_state.dart +++ b/lib/features/profile/presentation/cubits/profile_user_state.dart @@ -7,6 +7,7 @@ abstract class ProfileUserState with _$ProfileUserState { const factory ProfileUserState({ @Default(false) bool isLoading, User? user, + ProfileStatsHistorySnapshot? historySnapshot, ProfileFailure? failure, }) = _ProfileUserState; } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index e767018c..4d79ceeb 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -8,14 +8,18 @@ import '../../../../../core/constants/app_assets.dart'; import '../../../../../core/constants/app_strings.dart'; import '../../../../../core/router/router_paths.dart'; import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.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 '../../../auth/domain/entities/user.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; import '../widgets/edit_profile_dialog.dart'; +import '../widgets/stats/profile_history_dialog.dart'; +import '../widgets/stats/stats_section_widget.dart'; import '../widgets/user_section_widget.dart'; /// Authenticated profile page with the user section only. @@ -38,6 +42,10 @@ class ProfilePage extends StatelessWidget { unawaited(context.push(AppRoutePaths.forgotPasswordPath)); } + Future _openHistoryDialog(BuildContext context) async { + await showProfileHistoryDialog(context); + } + @override Widget build(BuildContext context) { final textTheme = AppTextTheme.of(context); @@ -59,39 +67,55 @@ class ProfilePage extends StatelessWidget { ), ], ), - body: BlocBuilder( - builder: (context, state) { - final user = state.user; - if (user == null) { - return _ProfileUserFallbackState( - isLoading: state.isLoading, - onRetryPressed: () => context.read().refresh(), - ); - } - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 28, 24, 132), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - AppStrings.profileGreeting(user.name), - style: textTheme.bodyMedium.copyWith( - fontSize: 18, - height: 27 / 18, - fontWeight: FontWeight.w500, - color: colorTheme.onSurface, - ), - ), - const SizedBox(height: 24), - UserSectionWidget( - user: user, - onEditPressed: () => _openEditProfileDialog(context, user), - onChangePasswordPressed: () => _openChangePasswordDialog(context), - ), - ], - ), - ); + body: BlocListener( + listenWhen: (previous, current) => previous.historySnapshot != current.historySnapshot, + listener: (context, state) { + final historySnapshot = state.historySnapshot; + if (historySnapshot == null) return; + + context.read().setHistorySnapshot(historySnapshot); }, + child: BlocBuilder( + builder: (context, state) { + final user = state.user; + if (user == null) { + return _ProfileUserFallbackState( + isLoading: state.isLoading, + onRetryPressed: () => context.read().refresh(), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 132), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.profileGreeting(user.name), + style: textTheme.bodyMedium.copyWith( + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 24), + UserSectionWidget( + user: user, + onEditPressed: () => _openEditProfileDialog(context, user), + onChangePasswordPressed: () => _openChangePasswordDialog(context), + ), + const SizedBox(height: 36), + const StatsSectionWidget(), + const SizedBox(height: 20), + SecondaryButton( + onPressed: () => _openHistoryDialog(context), + child: const Text(AppStrings.profileStatsHistoryButton), + ), + ], + ), + ); + }, + ), ), ); } diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 0d6b399a..f6769b9b 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -5,6 +5,8 @@ import '../../../../../core/di/di.dart'; import '../../../auth/domain/entities/user.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../../domain/repositories/profile_repository.dart'; +import '../../domain/repositories/profile_statistics_repository.dart'; +import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import 'profile_page.dart'; @@ -21,11 +23,20 @@ class ProfilePageBuilder extends StatelessWidget { orElse: () => null, ), ); - return BlocProvider( - create: (_) => ProfileUserCubit( - di(), - seedUser: initialUser, - )..refresh(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => ProfileUserCubit( + di(), + seedUser: initialUser, + )..refresh(), + ), + BlocProvider( + create: (_) => ProfileStatisticsCubit( + di(), + )..loadInitial(), + ), + ], child: const ProfilePage(), ); } diff --git a/lib/features/profile/presentation/widgets/profile_dialog_shell.dart b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart index 514ec8e5..fef6da95 100644 --- a/lib/features/profile/presentation/widgets/profile_dialog_shell.dart +++ b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart @@ -9,17 +9,20 @@ Future showProfileDialog( BuildContext context, { required Widget child, required EdgeInsets insetPadding, + EdgeInsets? contentPadding, + bool isBarrierDismissible = false, }) { return showDialog( context: context, - barrierDismissible: false, + barrierDismissible: isBarrierDismissible, barrierColor: AppColorTheme.of(context).onSurface.withValues(alpha: 0.16), builder: (_) => PopScope( - canPop: false, + canPop: isBarrierDismissible, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), child: ProfileDialogShell( insetPadding: insetPadding, + contentPadding: contentPadding, child: child, ), ), @@ -32,12 +35,16 @@ class ProfileDialogShell extends StatelessWidget { /// Dialog outer insets. final EdgeInsets insetPadding; + /// Dialog content padding. + final EdgeInsets? contentPadding; + /// Dialog content. final Widget child; /// Creates an instance of [ProfileDialogShell]. const ProfileDialogShell({ required this.insetPadding, + this.contentPadding = const EdgeInsets.symmetric(horizontal: 28, vertical: 40), required this.child, super.key, }); @@ -50,7 +57,7 @@ class ProfileDialogShell extends StatelessWidget { backgroundColor: colorTheme.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), + padding: contentPadding, child: child, ), ); diff --git a/lib/features/profile/presentation/widgets/profile_statistics_trend_chart.dart b/lib/features/profile/presentation/widgets/profile_statistics_trend_chart.dart new file mode 100644 index 00000000..af187ea1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_statistics_trend_chart.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; + +/// Lightweight list-based trend view for profile statistics. +class ProfileStatisticsTrendChart extends StatelessWidget { + /// Trend rows to render. + final List exercises; + + /// Creates an instance of [ProfileStatisticsTrendChart]. + const ProfileStatisticsTrendChart({ + required this.exercises, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: exercises + .map( + (exercise) => Padding( + padding: EdgeInsets.only( + bottom: exercise == exercises.last ? 0 : 16, + ), + child: _TrendExerciseRow(exercise: exercise), + ), + ) + .toList(growable: false), + ); + } +} + +final class _TrendExerciseRow extends StatelessWidget { + final TrendExerciseData exercise; + + const _TrendExerciseRow({required this.exercise}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final details = [ + if (exercise.scoreLabel != null && exercise.scoreLabel!.isNotEmpty) exercise.scoreLabel!, + if (exercise.weightUsed != null && exercise.weightUsed!.isNotEmpty) + '${exercise.weightUsed} кг', + ]; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.background, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colorTheme.outline.withValues(alpha: 0.7)), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + exercise.exerciseName, + maxLines: 2, + style: textTheme.bodyMedium.copyWith( + fontSize: 13, + height: 19 / 13, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${exercise.scorePercent}%', + style: textTheme.bodyMedium.copyWith( + fontSize: 13, + height: 19 / 13, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + ], + ), + if (details.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + details.join(' • '), + style: textTheme.bodySmall.copyWith( + fontSize: 11, + height: 16 / 11, + color: colorTheme.darkHint, + ), + ), + ], + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: (exercise.scorePercent / 100).clamp(0, 1), + minHeight: 8, + backgroundColor: colorTheme.disabled.withValues(alpha: 0.25), + valueColor: AlwaysStoppedAnimation( + _resolveProgressColor(colorTheme, exercise), + ), + ), + ), + ], + ), + ), + ); + } +} + +Color _resolveProgressColor(AppColorTheme colorTheme, TrendExerciseData exercise) { + final normalized = (exercise.reaction ?? exercise.scoreLabel ?? '').trim().toLowerCase(); + if (normalized.contains('bad') || normalized.contains('плох')) { + return colorTheme.error.withValues(alpha: 0.75); + } + if (normalized.contains('good') || normalized.contains('отлич') || normalized.contains('хорош')) { + return colorTheme.primary; + } + return colorTheme.secondary; +} diff --git a/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart b/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart new file mode 100644 index 00000000..5fa366a1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../../core/constants/app_strings.dart'; +import '../../../../../../uikit/buttons/button_state.dart'; +import '../../../../../../uikit/buttons/option_button.dart'; +import '../../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../../../uikit/buttons/button_size.dart'; +import '../../../domain/entities/profile_statistics/profile_history_tab.dart'; +import '../../../domain/entities/profile_stats_history_snapshot.dart'; +import '../../cubits/profile_statistics_cubit.dart'; +import '../profile_dialog_shell.dart'; + +/// Opens the profile statistics history dialog. +Future showProfileHistoryDialog(BuildContext context) { + final cubit = context.read(); + cubit.selectHistoryTab(ProfileHistoryTab.subscriptions); + + return showProfileDialog( + context, + insetPadding: const EdgeInsets.symmetric(horizontal: 32.5), + contentPadding: const EdgeInsets.all(32), + isBarrierDismissible: true, + child: BlocProvider.value( + value: cubit, + child: const ProfileHistoryDialog(), + ), + ); +} + +/// Dialog with local tabs for profile history snapshot. +class ProfileHistoryDialog extends StatelessWidget { + /// Creates an instance of [ProfileHistoryDialog]. + const ProfileHistoryDialog({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HistoryTabs( + selectedTab: state.selectedHistoryTab, + isEnabled: state.historySnapshot != null, + onSelected: (tab) => context.read().selectHistoryTab(tab), + ), + const SizedBox(height: 32), + if (state.historySnapshot == null) + const _HistoryLoadingState() + else + _HistoryContent( + snapshot: state.historySnapshot!, + selectedTab: state.selectedHistoryTab, + ), + ], + ); + }, + ); + } +} + +final class _HistoryTabs extends StatelessWidget { + final ProfileHistoryTab selectedTab; + final bool isEnabled; + final ValueChanged onSelected; + + const _HistoryTabs({ + required this.selectedTab, + required this.isEnabled, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final buttonState = isEnabled ? ButtonState.enabled : ButtonState.disabled; + + return LayoutBuilder( + builder: (context, constraints) { + final buttonWidth = (constraints.maxWidth - 10) / 2; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + SizedBox( + width: buttonWidth, + child: OptionButton( + size: ButtonSize.small, + state: buttonState, + isSelected: selectedTab == ProfileHistoryTab.subscriptions, + onPressed: () => onSelected(ProfileHistoryTab.subscriptions), + child: const Text(AppStrings.profileStatsHistorySubscriptionsTab), + ), + ), + SizedBox( + width: buttonWidth, + child: OptionButton( + size: ButtonSize.small, + state: buttonState, + isSelected: selectedTab == ProfileHistoryTab.workouts, + onPressed: () => onSelected(ProfileHistoryTab.workouts), + child: const Text(AppStrings.profileStatsHistoryWorkoutsTab), + ), + ), + SizedBox( + width: buttonWidth, + child: OptionButton( + size: ButtonSize.small, + state: buttonState, + isSelected: selectedTab == ProfileHistoryTab.tests, + onPressed: () => onSelected(ProfileHistoryTab.tests), + child: const Text(AppStrings.profileStatsHistoryTestsTab), + ), + ), + ], + ); + }, + ); + } +} + +final class _HistoryLoadingState extends StatelessWidget { + const _HistoryLoadingState(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ), + ); + } +} + +final class _HistoryContent extends StatelessWidget { + final ProfileStatsHistorySnapshot snapshot; + final ProfileHistoryTab selectedTab; + + const _HistoryContent({ + required this.snapshot, + required this.selectedTab, + }); + + @override + Widget build(BuildContext context) { + final content = switch (selectedTab) { + ProfileHistoryTab.subscriptions => _buildSubscriptionContent(), + ProfileHistoryTab.workouts => _buildWorkoutContent(), + ProfileHistoryTab.tests => _buildTestContent(), + }; + + return content; + } + + Widget _buildSubscriptionContent() { + final subscription = snapshot.activeSubscription; + if (subscription == null) { + return const _HistoryEmptyState( + message: AppStrings.profileStatsHistorySubscriptionEmpty, + ); + } + + return _HistoryValueList( + items: [ + _HistoryValueItem( + label: AppStrings.profileStatsHistoryNameLabel, + value: subscription.name, + ), + _HistoryValueItem( + label: AppStrings.profileStatsHistoryPriceLabel, + value: subscription.price, + ), + _HistoryValueItem( + label: AppStrings.profileStatsHistoryPeriodLabel, + value: '${_formatDate(subscription.startDate)}-${_formatDate(subscription.endDate)}', + ), + ], + ); + } + + Widget _buildWorkoutContent() { + final workout = snapshot.latestWorkout; + if (workout == null) { + return const _HistoryEmptyState( + message: AppStrings.profileStatsHistoryWorkoutEmpty, + ); + } + + return _HistoryValueList( + items: [ + _HistoryValueItem( + label: AppStrings.profileStatsHistoryNameLabel, + value: workout.title, + ), + _HistoryValueItem( + label: AppStrings.profileStatsHistoryCompletedLabel, + value: _formatDate(workout.completedAt), + ), + ], + ); + } + + Widget _buildTestContent() { + final test = snapshot.latestTest; + if (test == null) { + return const _HistoryEmptyState( + message: AppStrings.profileStatsHistoryTestEmpty, + ); + } + + return _HistoryValueList( + items: [ + _HistoryValueItem( + label: AppStrings.profileStatsHistoryNameLabel, + value: test.title, + ), + _HistoryValueItem( + label: AppStrings.profileStatsHistoryCompletedLabel, + value: _formatDate(test.completedAt), + ), + ], + ); + } +} + +final class _HistoryValueList extends StatelessWidget { + final List<_HistoryValueItem> items; + + const _HistoryValueList({required this.items}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: items + .map( + (item) => Padding( + padding: EdgeInsets.only(bottom: item == items.last ? 0 : 8), + child: _HistoryValueRow(item: item), + ), + ) + .toList(growable: false), + ); + } +} + +final class _HistoryValueRow extends StatelessWidget { + final _HistoryValueItem item; + + const _HistoryValueRow({required this.item}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${item.label}: ', + style: textTheme.bodyMedium.copyWith( + color: colorTheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + TextSpan( + text: item.value, + style: textTheme.bodyMedium.copyWith( + color: colorTheme.onSurface, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } +} + +final class _HistoryEmptyState extends StatelessWidget { + final String message; + + const _HistoryEmptyState({required this.message}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + message, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.darkHint), + ), + ); + } +} + +final class _HistoryValueItem { + final String label; + final String value; + + const _HistoryValueItem({ + required this.label, + required this.value, + }); +} + +String _formatDate(String rawValue) { + if (rawValue.contains('.')) { + return rawValue.split(' ').first; + } + + final normalizedValue = rawValue.contains(' ') ? rawValue.replaceFirst(' ', 'T') : rawValue; + final dateTime = DateTime.tryParse(normalizedValue); + if (dateTime == null) return rawValue; + + final day = dateTime.day.toString().padLeft(2, '0'); + final month = dateTime.month.toString().padLeft(2, '0'); + return '$day.$month'; +} diff --git a/lib/features/profile/presentation/widgets/stats/profile_statistics_bar_chart.dart b/lib/features/profile/presentation/widgets/stats/profile_statistics_bar_chart.dart new file mode 100644 index 00000000..9a3303f5 --- /dev/null +++ b/lib/features/profile/presentation/widgets/stats/profile_statistics_bar_chart.dart @@ -0,0 +1,287 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../../uikit/themes/text/app_text_theme.dart'; + +/// Single bar item used by [ProfileStatisticsBarChart]. +final class ProfileStatisticsBarChartItem { + /// Axis label rendered under the bar. + final String label; + + /// Numeric value represented by the bar. + final double value; + + /// Creates an instance of [ProfileStatisticsBarChartItem]. + const ProfileStatisticsBarChartItem({ + required this.label, + required this.value, + }); +} + +/// Lightweight vertical bar chart for the profile statistics section. +class ProfileStatisticsBarChart extends StatelessWidget { + /// Bars rendered inside the chart. + final List items; + + /// Creates an instance of [ProfileStatisticsBarChart]. + const ProfileStatisticsBarChart({ + required this.items, + super.key, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final maxValue = items.fold(0, (current, item) => math.max(current, item.value)); + final normalizedMaxValue = _normalizeMaxValue(maxValue); + final axisValues = _buildAxisValues(normalizedMaxValue); + final visibleLabelIndexes = _buildVisibleLabelIndexes(items.length); + + return SizedBox( + height: 280, + child: LayoutBuilder( + builder: (context, constraints) { + final plotWidth = math.max(0.0, constraints.maxWidth - 74); + final slotPadding = _resolveSlotPadding(items.length); + final barWidth = _resolveBarWidth( + itemCount: items.length, + availableWidth: plotWidth, + slotPadding: slotPadding, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 42, + height: 235, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: axisValues + .map( + (value) => Text( + _formatAxisValue(value), + style: textTheme.body, + ), + ) + .toList(growable: false), + ), + ), + ), + const SizedBox(width: 11), + SizedBox( + height: 238, + child: VerticalDivider( + color: colorTheme.outline, + width: 1, + thickness: 1, + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 235, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: _buildBarChildren( + items, + barWidth: barWidth, + slotPadding: slotPadding, + maxValue: normalizedMaxValue, + ), + ), + ), + const SizedBox(height: 10), + Divider( + color: colorTheme.outline, + height: 1, + thickness: 1, + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildLabelChildren( + items, + slotPadding: slotPadding, + textStyle: textTheme.body, + visibleLabelIndexes: visibleLabelIndexes, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +final class _Bar extends StatelessWidget { + final double width; + final double value; + final double maxValue; + + const _Bar({ + required this.width, + required this.value, + required this.maxValue, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final heightFactor = maxValue <= 0 ? 0.0 : (value / maxValue).clamp(0.0, 1.0); + + return Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + heightFactor: heightFactor, + alignment: Alignment.bottomCenter, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.secondary.withValues(alpha: 0.2), + ), + child: SizedBox(width: width), + ), + ), + ); + } +} + +List _buildAxisValues(double maxValue) { + final step = maxValue / 5; + return List.generate( + 5, + (index) => maxValue - (step * index), + ); +} + +double _normalizeMaxValue(double value) { + if (value <= 0) return 1; + + var magnitude = 1.0; + var normalized = value; + while (normalized >= 10) { + normalized /= 10; + magnitude *= 10; + } + + final rounded = switch (normalized) { + <= 1 => 1.0, + <= 2 => 2.0, + <= 5 => 5.0, + _ => 10.0, + }; + + return rounded * magnitude; +} + +String _formatAxisValue(double value) { + if (value >= 1000) return value.toStringAsFixed(0); + if (value % 1 == 0) return value.toInt().toString(); + return value.toStringAsFixed(1); +} + +List _buildBarChildren( + List items, { + required double barWidth, + required double slotPadding, + required double maxValue, +}) { + return items + .map( + (item) => Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: slotPadding), + child: Align( + alignment: Alignment.bottomCenter, + child: _Bar( + width: barWidth, + value: item.value, + maxValue: maxValue, + ), + ), + ), + ), + ) + .toList(growable: false); +} + +List _buildLabelChildren( + List items, { + required double slotPadding, + required TextStyle textStyle, + required Set visibleLabelIndexes, +}) { + return items.indexed + .map((entry) { + final index = entry.$1; + final item = entry.$2; + + return Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: slotPadding), + child: visibleLabelIndexes.contains(index) + ? Align( + alignment: Alignment.topCenter, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item.label, + maxLines: 1, + style: textStyle, + ), + ), + ) + : const SizedBox.expand(), + ), + ); + }) + .toList(growable: false); +} + +Set _buildVisibleLabelIndexes(int itemCount) { + if (itemCount <= 0) return const {}; + if (itemCount <= 12) { + return Set.from(List.generate(itemCount, (index) => index)); + } + + const targetLabelCount = 6; + final step = ((itemCount - 1) / (targetLabelCount - 1)).ceil(); + final indexes = {0, itemCount - 1}; + + for (var index = step; index < itemCount - 1; index += step) { + indexes.add(index); + } + + return indexes; +} + +double _resolveSlotPadding(int itemCount) { + if (itemCount <= 7) return 6; + if (itemCount <= 12) return 2; + return 1; +} + +double _resolveBarWidth({ + required int itemCount, + required double availableWidth, + required double slotPadding, +}) { + if (itemCount <= 0) return 0; + + final slotWidth = availableWidth / itemCount; + final resolvedWidth = slotWidth - (slotPadding * 2); + return resolvedWidth.clamp(4.0, 20.0); +} diff --git a/lib/features/profile/presentation/widgets/stats/stats_section_widget.dart b/lib/features/profile/presentation/widgets/stats/stats_section_widget.dart new file mode 100644 index 00000000..d33772b9 --- /dev/null +++ b/lib/features/profile/presentation/widgets/stats/stats_section_widget.dart @@ -0,0 +1,1194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../../core/constants/app_assets.dart'; +import '../../../../../../core/constants/app_strings.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/images/svg_picture_widget.dart'; +import '../../../../../../uikit/menus/app_selection_dropdown.dart'; +import '../../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../../../uikit/buttons/button_size.dart'; +import '../../../domain/entities/profile_statistics/frequency_period.dart'; +import '../../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../../domain/entities/profile_statistics/profile_exercise_option.dart'; +import '../../../domain/entities/profile_statistics/profile_statistics_mode.dart'; +import '../../../domain/entities/profile_statistics/profile_workout_option.dart'; +import '../../cubits/profile_statistics_cubit.dart'; +import '../profile_statistics_trend_chart.dart'; +import 'profile_statistics_bar_chart.dart'; + +enum _StatsDropdown { + category, + period, +} + +/// The second profile section with user statistics and selectors. +class StatsSectionWidget extends StatefulWidget { + /// Creates an instance of [StatsSectionWidget]. + const StatsSectionWidget({super.key}); + + @override + State createState() => _StatsSectionWidgetState(); +} + +class _StatsSectionWidgetState extends State { + final _categoryLayerLink = LayerLink(); + final _periodLayerLink = LayerLink(); + final _dropdownTapRegionGroupId = Object(); + final _dropdownOverlayController = OverlayPortalController(); + + _StatsDropdown? _openDropdown; + + void _toggleDropdown(_StatsDropdown dropdown) { + setState(() { + _openDropdown = _openDropdown == dropdown ? null : dropdown; + }); + if (_openDropdown == null) { + _dropdownOverlayController.hide(); + return; + } + _dropdownOverlayController.show(); + } + + void _closeDropdown() { + if (_openDropdown == null) return; + setState(() => _openDropdown = null); + _dropdownOverlayController.hide(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final categoryItems = _buildCategoryItems(state); + final isCategoryEnabled = + state.mode != ProfileStatisticsMode.frequency && categoryItems.isNotEmpty; + final showCategoryDropdown = _openDropdown == _StatsDropdown.category && isCategoryEnabled; + final showPeriodDropdown = + _openDropdown == _StatsDropdown.period && state.mode == ProfileStatisticsMode.frequency; + + return OverlayPortal( + controller: _dropdownOverlayController, + overlayChildBuilder: (context) => Stack( + clipBehavior: Clip.none, + children: [ + if (showCategoryDropdown) + CompositedTransformFollower( + link: _categoryLayerLink, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomLeft, + offset: const Offset(0, 8), + child: TapRegion( + groupId: _dropdownTapRegionGroupId, + onTapOutside: (_) => _closeDropdown(), + child: AppSelectionDropdown( + mode: AppSelectionDropdownMode.single, + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: 188, + maxWidth: 240, + ), + items: categoryItems, + selectedValues: _selectedCategoryValues(state), + onChanged: (selectedValues) { + if (selectedValues.isEmpty) return; + final selectedValue = selectedValues.first; + + _closeDropdown(); + final cubit = context.read(); + switch (state.mode) { + case ProfileStatisticsMode.volume: + cubit.selectExercise(selectedValue); + case ProfileStatisticsMode.frequency: + return; + case ProfileStatisticsMode.trend: + cubit.selectWorkout(selectedValue); + } + }, + ), + ), + ), + if (showPeriodDropdown) + CompositedTransformFollower( + link: _periodLayerLink, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomLeft, + offset: const Offset(0, 8), + child: TapRegion( + groupId: _dropdownTapRegionGroupId, + onTapOutside: (_) => _closeDropdown(), + child: AppSelectionDropdown( + mode: AppSelectionDropdownMode.single, + constraints: const BoxConstraints( + maxHeight: 280, + minWidth: 188, + maxWidth: 220, + ), + items: FrequencyPeriod.values + .map( + (period) => AppSelectionDropdownItem( + value: period, + label: _frequencyPeriodLabel(period), + ), + ) + .toList(growable: false), + selectedValues: {state.selectedFrequencyPeriod}, + onChanged: (selectedValues) { + if (selectedValues.isEmpty) return; + final selectedValue = selectedValues.first; + + _closeDropdown(); + context.read().selectFrequencyPeriod(selectedValue); + }, + ), + ), + ), + ], + ), + child: TapRegion( + groupId: _dropdownTapRegionGroupId, + onTapOutside: (_) => _closeDropdown(), + child: AppCard( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _StatsSummaryRow(state: state), + const SizedBox(height: 24), + _ModeButtons( + state: state, + onSelected: (mode) { + _closeDropdown(); + context.read().selectMode(mode); + }, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: TapRegion( + groupId: _dropdownTapRegionGroupId, + onTapOutside: (_) => _closeDropdown(), + child: CompositedTransformTarget( + link: _categoryLayerLink, + child: SizedBox( + width: 168, + child: _SelectionButton( + label: _resolveCategoryButtonLabel(state), + onPressed: isCategoryEnabled + ? () => _toggleDropdown(_StatsDropdown.category) + : () {}, + buttonState: isCategoryEnabled + ? ButtonState.enabled + : ButtonState.disabled, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + AppStrings.profileStatsTitle, + style: AppTextTheme.of(context).title.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w400, + ), + ), + if (state.mode != ProfileStatisticsMode.trend) ...[ + const SizedBox(height: 12), + _PeriodControl( + state: state, + periodLayerLink: _periodLayerLink, + dropdownTapRegionGroupId: _dropdownTapRegionGroupId, + onToggleDropdown: () => _toggleDropdown(_StatsDropdown.period), + onPreviousPressed: () { + _closeDropdown(); + context.read().loadPreviousPeriod(); + }, + onNextPressed: () { + _closeDropdown(); + context.read().loadNextPeriod(); + }, + onTapOutside: _closeDropdown, + ), + ], + const SizedBox(height: 24), + if (state.isLoading && !_hasCurrentPayload(state)) + const _StatsLoadingState() + else if (state.failure != null) + _StatsErrorState( + onRetryPressed: () { + _closeDropdown(); + context.read().reload(); + }, + ) + else + _StatsContent(state: state), + ], + ), + ), + ), + ); + }, + ); + } +} + +final class _StatsSummaryRow extends StatelessWidget { + final ProfileStatisticsState state; + + const _StatsSummaryRow({required this.state}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final leftText = switch (state.mode) { + ProfileStatisticsMode.volume => + state.volumeData?.title.isNotEmpty == true + ? state.volumeData!.title + : AppStrings.profileStatsExercisesButton, + ProfileStatisticsMode.frequency => + state.frequencyData?.label.isNotEmpty == true + ? state.frequencyData!.label + : _frequencyPeriodLabel(state.selectedFrequencyPeriod), + ProfileStatisticsMode.trend => + state.trendData?.title.isNotEmpty == true + ? state.trendData!.title + : AppStrings.profileStatsWorkoutsButton, + }; + final subtitle = state.mode == ProfileStatisticsMode.trend + ? state.trendData?.completedAtFormatted + : null; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + leftText, + maxLines: 2, + style: textTheme.body.copyWith( + fontSize: 14, + height: 21 / 14, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + overflow: TextOverflow.ellipsis, + ), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + style: textTheme.bodySmall.copyWith( + fontSize: 11, + height: 16 / 11, + color: colorTheme.darkHint, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + height: subtitle != null && subtitle.isNotEmpty ? 36 : 24, + child: VerticalDivider( + width: 1, + thickness: 1, + color: colorTheme.onSurface, + ), + ), + const SizedBox(width: 12), + switch (state.mode) { + ProfileStatisticsMode.frequency => _SummaryValueText( + value: AppStrings.profileStatsAveragePerWeek( + _formatAveragePerWeek(state.frequencyData?.averagePerWeek ?? 0), + ), + ), + ProfileStatisticsMode.volume => _SummaryScore( + percent: state.volumeData?.averageScorePercent ?? 0, + scoreLabel: state.volumeData?.averageScoreLabel ?? '', + ), + ProfileStatisticsMode.trend => _SummaryScore( + percent: state.trendData?.averageScorePercent ?? 0, + scoreLabel: state.trendData?.averageScoreLabel ?? '', + ), + }, + ], + ); + } +} + +final class _ModeButtons extends StatelessWidget { + final ProfileStatisticsState state; + final ValueChanged onSelected; + + const _ModeButtons({ + required this.state, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final isLoading = state.isLoading; + return Row( + children: [ + Expanded( + child: OptionButton( + size: ButtonSize.small, + state: isLoading ? ButtonState.disabled : ButtonState.enabled, + isSelected: state.mode == ProfileStatisticsMode.volume, + onPressed: () => onSelected(ProfileStatisticsMode.volume), + child: const Text(AppStrings.profileStatsVolumeMode), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OptionButton( + size: ButtonSize.small, + state: isLoading ? ButtonState.disabled : ButtonState.enabled, + isSelected: state.mode == ProfileStatisticsMode.frequency, + onPressed: () => onSelected(ProfileStatisticsMode.frequency), + child: const Text(AppStrings.profileStatsFrequencyMode), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OptionButton( + size: ButtonSize.small, + state: isLoading ? ButtonState.disabled : ButtonState.enabled, + isSelected: state.mode == ProfileStatisticsMode.trend, + onPressed: () => onSelected(ProfileStatisticsMode.trend), + child: const Text(AppStrings.profileStatsTrendMode), + ), + ), + ], + ); + } +} + +final class _PeriodControl extends StatelessWidget { + final ProfileStatisticsState state; + final LayerLink periodLayerLink; + final Object dropdownTapRegionGroupId; + final VoidCallback onToggleDropdown; + final VoidCallback onPreviousPressed; + final VoidCallback onNextPressed; + final VoidCallback onTapOutside; + + const _PeriodControl({ + required this.state, + required this.periodLayerLink, + required this.dropdownTapRegionGroupId, + required this.onToggleDropdown, + required this.onPreviousPressed, + required this.onNextPressed, + required this.onTapOutside, + }); + + @override + Widget build(BuildContext context) { + final canGoPrevious = switch (state.mode) { + ProfileStatisticsMode.volume => state.volumeData?.period.canGoPrevious ?? false, + ProfileStatisticsMode.frequency => true, + ProfileStatisticsMode.trend => false, + }; + final canGoNext = switch (state.mode) { + ProfileStatisticsMode.volume => state.volumeData?.period.canGoNext ?? false, + ProfileStatisticsMode.frequency => state.selectedFrequencyOffset > 0, + ProfileStatisticsMode.trend => false, + }; + final isFrequency = state.mode == ProfileStatisticsMode.frequency; + final periodPresentation = _resolvePeriodPresentation(state); + + return Row( + children: [ + _ArrowButton( + icon: Icons.chevron_left_rounded, + isEnabled: canGoPrevious && !state.isLoading, + onPressed: onPreviousPressed, + ), + const SizedBox(width: 12), + Expanded( + child: isFrequency + ? TapRegion( + groupId: dropdownTapRegionGroupId, + onTapOutside: (_) => onTapOutside(), + child: CompositedTransformTarget( + link: periodLayerLink, + child: _PeriodLabel( + title: periodPresentation.title, + dateRange: periodPresentation.dateRange, + year: periodPresentation.year, + onPressed: state.isLoading ? null : onToggleDropdown, + ), + ), + ) + : _PeriodLabel( + title: periodPresentation.title, + dateRange: periodPresentation.dateRange, + year: periodPresentation.year, + ), + ), + const SizedBox(width: 12), + _ArrowButton( + icon: Icons.chevron_right_rounded, + isEnabled: canGoNext && !state.isLoading, + onPressed: onNextPressed, + ), + ], + ); + } +} + +final class _StatsContent extends StatelessWidget { + final ProfileStatisticsState state; + + const _StatsContent({required this.state}); + + @override + Widget build(BuildContext context) { + final content = switch (state.mode) { + ProfileStatisticsMode.volume => _buildVolumeContent(), + ProfileStatisticsMode.frequency => _buildFrequencyContent(), + ProfileStatisticsMode.trend => _buildTrendContent(), + }; + + if (state.isLoading) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerRight, + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColorTheme.of(context).primary), + ), + ), + ), + const SizedBox(height: 12), + content, + ], + ); + } + + return content; + } + + Widget _buildVolumeContent() { + final data = state.volumeData; + if (data == null || !data.hasData || data.chart.isEmpty) { + return const _StatsEmptyState(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionTitle(title: AppStrings.profileStatsVolumeChartTitle), + const SizedBox(height: 20), + ProfileStatisticsBarChart( + items: data.chart + .map( + (item) => ProfileStatisticsBarChartItem( + label: item.label, + value: item.value, + ), + ) + .toList(growable: false), + ), + ], + ); + } + + Widget _buildFrequencyContent() { + final data = state.frequencyData; + if (data == null || !data.hasData || data.chart.isEmpty) { + return const _StatsEmptyState(); + } + + final chartItems = _buildFrequencyChartItems(data); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionTitle(title: AppStrings.profileStatsFrequencyChartTitle), + const SizedBox(height: 20), + ProfileStatisticsBarChart(items: chartItems), + ], + ); + } + + Widget _buildTrendContent() { + final data = state.trendData; + if (data == null || !data.hasData || data.exercises.isEmpty) { + return const _StatsEmptyState(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionTitle(title: AppStrings.profileStatsTrendChartTitle), + const SizedBox(height: 20), + ProfileStatisticsTrendChart(exercises: data.exercises), + ], + ); + } + + List _buildFrequencyChartItems( + FrequencyStatisticsData data, + ) { + return switch (data.period) { + FrequencyPeriod.week => + data.chart + .map( + (item) => ProfileStatisticsBarChartItem( + label: item.label, + value: item.count.toDouble(), + ), + ) + .toList(growable: false), + FrequencyPeriod.month || FrequencyPeriod.threeMonths => + data.chart + .map( + (item) => ProfileStatisticsBarChartItem( + label: item.shortLabel, + value: item.count.toDouble(), + ), + ) + .toList(growable: false), + FrequencyPeriod.sixMonths => _buildMonthlyFrequencyChartItems( + data.chart, + targetCount: 6, + ), + FrequencyPeriod.year => _buildMonthlyFrequencyChartItems( + data.chart, + targetCount: 12, + ), + }; + } + + List _buildMonthlyFrequencyChartItems( + List chart, { + required int targetCount, + }) { + final buckets = {}; + + for (final item in chart) { + final date = _parseFrequencyBucketDate(item); + if (date == null) { + final bucketKey = '${buckets.length}'; + final existingBucket = buckets[bucketKey]; + if (existingBucket != null) { + buckets[bucketKey] = existingBucket.copyWith( + value: existingBucket.value + item.count.toDouble(), + ); + continue; + } + + buckets[bucketKey] = _FrequencyMonthBucket( + label: item.shortLabel, + value: item.count.toDouble(), + sortKey: DateTime(1970), + ); + continue; + } + + final bucketKey = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + final existingBucket = buckets[bucketKey]; + if (existingBucket != null) { + buckets[bucketKey] = existingBucket.copyWith( + value: existingBucket.value + item.count.toDouble(), + ); + continue; + } + + buckets[bucketKey] = _FrequencyMonthBucket( + label: date.month.toString(), + value: item.count.toDouble(), + sortKey: DateTime(date.year, date.month), + ); + } + + final sortedBuckets = buckets.values.toList(growable: false) + ..sort((left, right) => left.sortKey.compareTo(right.sortKey)); + final visibleBuckets = sortedBuckets.length > targetCount + ? sortedBuckets.sublist(sortedBuckets.length - targetCount) + : sortedBuckets; + + return visibleBuckets.indexed + .map( + (entry) => ProfileStatisticsBarChartItem( + label: '${entry.$1 + 1}', + value: entry.$2.value, + ), + ) + .toList(growable: false); + } + + DateTime? _parseFrequencyBucketDate(FrequencyChartBarData item) { + final rawValue = item.startDate ?? item.endDate; + if (rawValue == null || rawValue.isEmpty) return null; + + return DateTime.tryParse(rawValue); + } +} + +final class _FrequencyMonthBucket { + final String label; + final double value; + final DateTime sortKey; + + const _FrequencyMonthBucket({ + required this.label, + required this.value, + required this.sortKey, + }); + + _FrequencyMonthBucket copyWith({ + String? label, + double? value, + DateTime? sortKey, + }) { + return _FrequencyMonthBucket( + label: label ?? this.label, + value: value ?? this.value, + sortKey: sortKey ?? this.sortKey, + ); + } +} + +final class _SectionTitle extends StatelessWidget { + final String title; + + const _SectionTitle({required this.title}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Text( + title, + style: textTheme.label.copyWith( + color: colorTheme.darkHint, + ), + ); + } +} + +final class _StatsLoadingState extends StatelessWidget { + const _StatsLoadingState(); + + @override + Widget build(BuildContext context) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } +} + +final class _StatsEmptyState extends StatelessWidget { + const _StatsEmptyState(); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + AppStrings.profileStatsEmpty, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith( + color: colorTheme.darkHint, + ), + ), + ); + } +} + +final class _StatsErrorState extends StatelessWidget { + final VoidCallback onRetryPressed; + + const _StatsErrorState({required this.onRetryPressed}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Column( + children: [ + Text( + AppStrings.profileStatsLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ); + } +} + +final class _SummaryValueText extends StatelessWidget { + final String value; + + const _SummaryValueText({required this.value}); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + return Text( + value, + textAlign: TextAlign.right, + style: textTheme.body, + ); + } +} + +final class _SummaryScore extends StatelessWidget { + final int percent; + final String scoreLabel; + + const _SummaryScore({ + required this.percent, + required this.scoreLabel, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.profileStatsAverageScoreLabel, + style: textTheme.body, + ), + const SizedBox(width: 8), + SvgPictureWidget.icon( + _resolveFaceAsset(scoreLabel), + height: 18, + color: colorTheme.hint, + ), + const SizedBox(width: 4), + Text( + '$percent%', + style: textTheme.body, + ), + ], + ); + } +} + +final class _SelectionButton extends StatelessWidget { + final String label; + final VoidCallback onPressed; + final ButtonState buttonState; + + const _SelectionButton({ + required this.label, + required this.onPressed, + required this.buttonState, + }); + + @override + Widget build(BuildContext context) { + return OptionButton( + size: ButtonSize.small, + state: buttonState, + onPressed: onPressed, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +final class _PeriodLabel extends StatelessWidget { + final String title; + final String dateRange; + final String year; + final VoidCallback? onPressed; + + const _PeriodLabel({ + required this.title, + required this.dateRange, + required this.year, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final content = DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(width: 8), + Text( + dateRange, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(width: 4), + SizedBox( + height: 12, + child: VerticalDivider( + width: 1, + thickness: 1, + color: colorTheme.outline, + ), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + year, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + ), + ], + ), + ), + ), + ); + + if (onPressed == null) return content; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: content, + ), + ); + } +} + +final class _ResolvedPeriodPresentation { + final String title; + final String dateRange; + final String year; + + const _ResolvedPeriodPresentation({ + required this.title, + required this.dateRange, + required this.year, + }); +} + +_ResolvedPeriodPresentation _resolvePeriodPresentation(ProfileStatisticsState state) { + return switch (state.mode) { + ProfileStatisticsMode.volume => _resolveVolumePeriodPresentation(state), + ProfileStatisticsMode.frequency => _resolveFrequencyPeriodPresentation(state), + ProfileStatisticsMode.trend => const _ResolvedPeriodPresentation( + title: '', + dateRange: '', + year: '', + ), + }; +} + +_ResolvedPeriodPresentation _resolveVolumePeriodPresentation(ProfileStatisticsState state) { + final period = state.volumeData?.period; + final fallbackRange = _resolveFrequencyRange( + FrequencyPeriod.week, + period?.weekOffset ?? 0, + ); + + return _ResolvedPeriodPresentation( + title: _resolveVolumePeriodTitle(period?.label), + dateRange: _formatPeriodDateRange( + period?.start.isNotEmpty == true ? period!.start : fallbackRange.start, + period?.end.isNotEmpty == true ? period!.end : fallbackRange.end, + ), + year: _formatPeriodYear( + period?.start.isNotEmpty == true ? period!.start : fallbackRange.start, + period?.end.isNotEmpty == true ? period!.end : fallbackRange.end, + ), + ); +} + +_ResolvedPeriodPresentation _resolveFrequencyPeriodPresentation(ProfileStatisticsState state) { + final period = state.frequencyData?.period ?? state.selectedFrequencyPeriod; + final offset = state.frequencyData?.offset ?? state.selectedFrequencyOffset; + final range = _resolveFrequencyRange(period, offset); + + return _ResolvedPeriodPresentation( + title: _frequencyPeriodLabel(period), + dateRange: _formatPeriodDateRange(range.start, range.end), + year: _formatPeriodYear(range.start, range.end), + ); +} + +String _resolveVolumePeriodTitle(String? label) { + if (label == null || label.isEmpty) return _frequencyPeriodLabel(FrequencyPeriod.week); + return label.replaceFirst(RegExp(r'\s+\d+$'), ''); +} + +({DateTime start, DateTime end}) _resolveFrequencyRange(FrequencyPeriod period, int offset) { + final now = DateTime.now(); + final currentDate = DateTime(now.year, now.month, now.day); + + return switch (period) { + FrequencyPeriod.week => _resolveWeekRange(currentDate, offset), + FrequencyPeriod.month => _resolveMonthRange(currentDate, offset), + FrequencyPeriod.threeMonths => _resolveMultiMonthRange( + currentDate, + offset, + monthSpan: 3, + ), + FrequencyPeriod.sixMonths => _resolveMultiMonthRange( + currentDate, + offset, + monthSpan: 6, + ), + FrequencyPeriod.year => _resolveYearRange(currentDate, offset), + }; +} + +({DateTime start, DateTime end}) _resolveWeekRange(DateTime currentDate, int offset) { + final currentWeekStart = currentDate.subtract(Duration(days: currentDate.weekday - 1)); + final start = currentWeekStart.subtract(Duration(days: offset * 7)); + final end = start.add(const Duration(days: 6)); + return (start: start, end: end); +} + +({DateTime start, DateTime end}) _resolveMonthRange(DateTime currentDate, int offset) { + final start = DateTime(currentDate.year, currentDate.month - offset); + final end = DateTime(start.year, start.month + 1, 0); + return (start: start, end: end); +} + +({DateTime start, DateTime end}) _resolveMultiMonthRange( + DateTime currentDate, + int offset, { + required int monthSpan, +}) { + final endMonthStart = DateTime(currentDate.year, currentDate.month - (offset * monthSpan)); + final start = DateTime(endMonthStart.year, endMonthStart.month - (monthSpan - 1)); + final end = DateTime(endMonthStart.year, endMonthStart.month + 1, 0); + return (start: start, end: end); +} + +({DateTime start, DateTime end}) _resolveYearRange(DateTime currentDate, int offset) { + final year = currentDate.year - offset; + return ( + start: DateTime(year), + end: DateTime(year, 12, 31), + ); +} + +String _formatPeriodDateRange(Object startValue, Object endValue) { + final start = _parseStatsDate(startValue); + final end = _parseStatsDate(endValue); + if (start == null || end == null) return ''; + + return '${_formatPeriodDate(start)} - ${_formatPeriodDate(end)}'; +} + +String _formatPeriodYear(Object startValue, Object endValue) { + final start = _parseStatsDate(startValue); + final end = _parseStatsDate(endValue); + if (start == null || end == null) return ''; + if (start.year == end.year) return start.year.toString(); + return '${_formatShortYear(start.year)}-${_formatShortYear(end.year)}'; +} + +DateTime? _parseStatsDate(Object rawValue) { + if (rawValue is DateTime) return rawValue; + if (rawValue is! String || rawValue.isEmpty) return null; + + final normalizedValue = rawValue.contains(' ') ? rawValue.replaceFirst(' ', 'T') : rawValue; + return DateTime.tryParse(normalizedValue); +} + +String _formatPeriodDate(DateTime dateTime) { + final day = dateTime.day.toString().padLeft(2, '0'); + final month = dateTime.month.toString().padLeft(2, '0'); + return '$day.$month'; +} + +String _formatShortYear(int year) { + return (year % 100).toString().padLeft(2, '0'); +} + +final class _ArrowButton extends StatelessWidget { + final IconData icon; + final bool isEnabled; + final VoidCallback onPressed; + + const _ArrowButton({ + required this.icon, + required this.isEnabled, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + return SizedBox( + width: 38, + height: 38, + child: OutlinedButton( + onPressed: isEnabled ? onPressed : null, + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + side: BorderSide(color: isEnabled ? colorTheme.outline : colorTheme.disabled), + ), + child: Icon( + icon, + size: 22, + color: isEnabled ? colorTheme.onSurface : colorTheme.disabled, + ), + ), + ); + } +} + +List> _buildCategoryItems(ProfileStatisticsState state) { + switch (state.mode) { + case ProfileStatisticsMode.volume: + return state.exerciseOptions + .map( + (option) => AppSelectionDropdownItem( + value: option.id, + label: option.name, + ), + ) + .toList(growable: false); + case ProfileStatisticsMode.frequency: + return const >[]; + case ProfileStatisticsMode.trend: + return state.workoutOptions + .map( + (option) => AppSelectionDropdownItem( + value: option.id, + label: option.title, + ), + ) + .toList(growable: false); + } +} + +Set _selectedCategoryValues(ProfileStatisticsState state) { + final selectedValue = switch (state.mode) { + ProfileStatisticsMode.volume => state.selectedExerciseId, + ProfileStatisticsMode.frequency => null, + ProfileStatisticsMode.trend => state.selectedWorkoutId, + }; + + if (selectedValue == null) return {}; + return {selectedValue}; +} + +String _resolveCategoryButtonLabel(ProfileStatisticsState state) { + switch (state.mode) { + case ProfileStatisticsMode.volume: + final selectedId = state.selectedExerciseId; + final selectedOption = _findExerciseOptionLabel( + state, + selectedId, + ); + return selectedOption?.name ?? AppStrings.profileStatsCategoriesButton; + case ProfileStatisticsMode.frequency: + return AppStrings.profileStatsCategoriesButton; + case ProfileStatisticsMode.trend: + final selectedId = state.selectedWorkoutId; + final selectedOption = _findWorkoutOptionLabel( + state, + selectedId, + ); + return selectedOption?.title ?? AppStrings.profileStatsWorkoutsButton; + } +} + +bool _hasCurrentPayload(ProfileStatisticsState state) { + return switch (state.mode) { + ProfileStatisticsMode.volume => state.volumeData != null, + ProfileStatisticsMode.frequency => state.frequencyData != null, + ProfileStatisticsMode.trend => state.trendData != null, + }; +} + +String _frequencyPeriodLabel(FrequencyPeriod period) { + return switch (period) { + FrequencyPeriod.week => 'Неделя', + FrequencyPeriod.month => 'Месяц', + FrequencyPeriod.threeMonths => '3 месяца', + FrequencyPeriod.sixMonths => '6 месяцев', + FrequencyPeriod.year => 'Год', + }; +} + +String _formatAveragePerWeek(double value) { + final roundedValue = value % 1 == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + return roundedValue.replaceFirst('.', ','); +} + +String _resolveFaceAsset(String rawValue) { + final normalizedValue = rawValue.trim().toLowerCase(); + if (normalizedValue.contains('bad') || normalizedValue.contains('плох')) { + return AppAssets.iconBadFace; + } + if (normalizedValue.contains('good') || + normalizedValue.contains('хорош') || + normalizedValue.contains('отлич')) { + return AppAssets.iconGoodFace; + } + return AppAssets.iconNormalFace; +} + +ProfileExerciseOption? _findExerciseOptionLabel(ProfileStatisticsState state, int? selectedId) { + for (final option in state.exerciseOptions) { + if (option.id == selectedId) return option; + } + return null; +} + +ProfileWorkoutOption? _findWorkoutOptionLabel(ProfileStatisticsState state, int? selectedId) { + for (final option in state.workoutOptions) { + if (option.id == selectedId) return option; + } + return null; +} diff --git a/lib/uikit/buttons/button_size.dart b/lib/uikit/buttons/button_size.dart new file mode 100644 index 00000000..cbe368a9 --- /dev/null +++ b/lib/uikit/buttons/button_size.dart @@ -0,0 +1,8 @@ +/// Shared button sizes used across the app. +enum ButtonSize { + /// Default large button. + large, + + /// Compact button. + small, +} diff --git a/lib/uikit/buttons/option_button.dart b/lib/uikit/buttons/option_button.dart index 0d5ff7c9..99ec8022 100644 --- a/lib/uikit/buttons/option_button.dart +++ b/lib/uikit/buttons/option_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../themes/colors/app_color_theme.dart'; import '../themes/text/app_text_theme.dart'; +import 'button_size.dart'; import 'button_state.dart'; /// Shared outlined option button. @@ -18,12 +19,16 @@ class OptionButton extends StatelessWidget { /// Whether the button is currently selected. final bool isSelected; + /// Canonical visual size of the button. + final ButtonSize size; + /// Creates an instance of [OptionButton]. const OptionButton({ this.state = ButtonState.enabled, required this.onPressed, required this.child, this.isSelected = false, + this.size = ButtonSize.large, super.key, }); @@ -32,16 +37,29 @@ class OptionButton extends StatelessWidget { final colorTheme = AppColorTheme.of(context); final textTheme = AppTextTheme.of(context); final isDisabled = state == ButtonState.disabled; + final height = switch (size) { + ButtonSize.large => 53.0, + ButtonSize.small => 42.0, + }; + final padding = switch (size) { + ButtonSize.large => const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ButtonSize.small => const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + }; + final textStyle = switch (size) { + ButtonSize.large => textTheme.bodyMedium, + ButtonSize.small => textTheme.body, + }; + return SizedBox( width: double.infinity, child: OutlinedButton( onPressed: isDisabled ? null : onPressed, style: OutlinedButton.styleFrom( - fixedSize: const Size.fromHeight(53), - padding: const EdgeInsets.all(16), + fixedSize: Size.fromHeight(height), + padding: padding, tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: textTheme.bodyMedium, + textStyle: textStyle, foregroundColor: colorTheme.onSurface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10)), diff --git a/lib/uikit/menus/app_selection_dropdown.dart b/lib/uikit/menus/app_selection_dropdown.dart index bedddb67..e3e58896 100644 --- a/lib/uikit/menus/app_selection_dropdown.dart +++ b/lib/uikit/menus/app_selection_dropdown.dart @@ -77,18 +77,19 @@ class AppSelectionDropdown extends StatelessWidget { Widget build(BuildContext context) { final colorTheme = AppColorTheme.of(context); final textTheme = AppTextTheme.of(context); - return DecoratedBox( - decoration: BoxDecoration( - color: colorTheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colorTheme.outline), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: ConstrainedBox( - constraints: constraints, + return ConstrainedBox( + constraints: constraints, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: ListView.builder( padding: EdgeInsets.zero, + shrinkWrap: true, itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; diff --git a/test/features/profile/data/mappers/profile_statistics_mapper_test.dart b/test/features/profile/data/mappers/profile_statistics_mapper_test.dart new file mode 100644 index 00000000..a4408fe7 --- /dev/null +++ b/test/features/profile/data/mappers/profile_statistics_mapper_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/frequency_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/mappers/profile_statistics_mapper.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; + +import '../../support/profile_statistics_dto_fixtures.dart'; + +void main() { + group('ProfileStatisticsMapper', () { + test('maps volume statistics dto to volume entity', () { + final result = createVolumeStatisticsDto().toEntity(); + + expect(result.exerciseId, testVolumeExerciseId); + expect(result.title, testVolumeExerciseTitle); + expect(result.averageScorePercent, testVolumeAverageScorePercent); + expect(result.period.weekOffset, testVolumeWeekOffset); + expect(result.chart.first.label, 'Пн'); + }); + + test('maps frequency statistics dto to frequency entity with fallback period', () { + final result = + createFrequencyStatisticsDto( + includePeriodInfo: false, + ).toEntity( + fallbackPeriod: FrequencyPeriod.year, + fallbackOffset: 2, + ); + + expect(result.period, FrequencyPeriod.year); + expect(result.offset, 2); + expect(result.averagePerWeek, 2.3); + }); + + test('maps weekly frequency dto without short_label', () { + final result = + createFrequencyStatisticsDto( + periodInfo: FrequencyPeriodInfoDto( + type: 'week', + offset: 0, + label: 'Текущяя неделя', + itemsCount: 7, + ), + chart: [ + FrequencyChartItemDto( + dayIndex: 0, + dayNumber: 1, + weekIndex: null, + weekNumber: null, + label: 'Пн', + dateFormatted: '30.03', + startDate: null, + endDate: null, + count: 0, + goal: null, + ), + ], + ).toEntity( + fallbackPeriod: FrequencyPeriod.month, + fallbackOffset: 0, + ); + + expect(result.period, FrequencyPeriod.week); + expect(result.chart.first.label, 'Пн'); + expect(result.chart.first.shortLabel, 'Пн'); + }); + + test('maps workout selector dto to profile workout options', () { + final result = createProfileWorkoutsResponseDto().data.toEntity(); + + expect(result, hasLength(1)); + expect(result.first.id, testTrendWorkoutId); + expect(result.first.title, testTrendWorkoutTitle); + expect(result.first.completedAtFormatted, '18.03.2026'); + }); + }); +} diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 21fbfa69..93d15bd4 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -10,6 +10,7 @@ import 'package:moveup_flutter/features/profile/data/dto/change_password_request import 'package:moveup_flutter/features/profile/data/dto/update_profile_request_dto.dart'; import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart'; import 'package:moveup_flutter/features/profile/data/repositories/profile_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; import '../../support/profile_dto_fixtures.dart'; @@ -248,6 +249,122 @@ void main() { }); }); + group('getStatsHistorySnapshot', () { + test('returns snapshot from cache after getUser succeeds', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + subscriptions: createProfileSubscriptionsDto(), + workouts: createProfileWorkoutsDto(), + tests: createProfileTestsDto(), + ), + ); + + // Act + final getUserResult = await repository.getUser(); + final historyResult = await repository.getStatsHistorySnapshot(); + + // Assert + expect(getUserResult.isSuccess, isTrue); + expect(historyResult.isSuccess, isTrue); + expect(historyResult.success, createProfileStatsHistorySnapshot()); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns latest sorted workout and test when cache is empty', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + subscriptions: createProfileSubscriptionsDto(), + workouts: createProfileWorkoutsDto( + history: [ + createProfileWorkoutHistoryItemDto( + id: 1, + title: 'older workout', + completedAt: '2026-03-10 10:30:00', + ), + createProfileWorkoutHistoryItemDto( + id: 2, + title: 'latest workout', + ), + ], + ), + tests: createProfileTestsDto( + history: [ + createProfileTestHistoryItemDto( + attemptId: 1, + title: 'older test', + completedAt: '2026-03-12 15:20:00', + ), + createProfileTestHistoryItemDto( + attemptId: 2, + title: 'latest test', + ), + ], + ), + ), + ); + + // Act + final result = await repository.getStatsHistorySnapshot(); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + const ProfileStatsHistorySnapshot( + activeSubscription: ProfileActiveSubscriptionSnapshot( + id: testProfileSubscriptionId, + name: testProfileSubscriptionName, + price: testProfileSubscriptionPrice, + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + latestWorkout: ProfileLatestWorkoutSnapshot( + id: 2, + title: 'latest workout', + completedAt: '2026-03-15 10:30:00', + ), + latestTest: ProfileLatestTestSnapshot( + attemptId: 2, + title: 'latest test', + completedAt: '2026-03-14 15:20:00', + ), + ), + ); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api returns server error', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getStatsHistorySnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + group('changePassword', () { test('returns success when api succeeds', () async { // Arrange diff --git a/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart b/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart new file mode 100644 index 00000000..8ae9dced --- /dev/null +++ b/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart @@ -0,0 +1,331 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/profile/data/remote/profile_statistics_api_client.dart'; +import 'package:moveup_flutter/features/profile/data/repositories/profile_statistics_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_statistics_repository.dart'; + +import '../../support/profile_statistics_dto_fixtures.dart'; +import 'profile_statistics_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockProfileStatisticsApiClient apiClient; + late ProfileStatisticsRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockProfileStatisticsApiClient(); + repository = ProfileStatisticsRepositoryImpl(logger, apiClient); + }); + + group('ProfileStatisticsRepositoryImpl', () { + group('getVolume()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when(apiClient.getVolume()).thenAnswer((_) async => createVolumeResponseDto()); + + // Act + final result = await repository.getVolume(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.exerciseId, testVolumeExerciseId); + expect(result.success!.title, testVolumeExerciseTitle); + expect(result.success!.averageScorePercent, testVolumeAverageScorePercent); + + verify(apiClient.getVolume()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics/volume', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getVolume()).thenThrow(exception); + + // Act + final result = await repository.getVolume(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getVolume()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getVolume()).thenThrow(exception); + + // Act + final result = await repository.getVolume(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getVolume()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getTrend()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when(apiClient.getTrend()).thenAnswer((_) async => createTrendResponseDto()); + + // Act + final result = await repository.getTrend(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.workoutId, testTrendWorkoutId); + expect(result.success!.title, testTrendWorkoutTitle); + expect(result.success!.averageScorePercent, 100); + + verify(apiClient.getTrend()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics/trend', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getTrend()).thenThrow(exception); + + // Act + final result = await repository.getTrend(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getTrend()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getTrend()).thenThrow(exception); + + // Act + final result = await repository.getTrend(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getTrend()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getFrequency()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when( + apiClient.getFrequency(period: 'month', offset: 0), + ).thenAnswer((_) async => createFrequencyResponseDto()); + + // Act + final result = await repository.getFrequency( + period: FrequencyPeriod.month, + offset: 0, + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.period, FrequencyPeriod.month); + expect(result.success!.label, testFrequencyLabel); + expect(result.success!.averagePerWeek, 2.3); + + verify(apiClient.getFrequency(period: 'month', offset: 0)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics/frequency', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getFrequency(period: 'month', offset: 0)).thenThrow(exception); + + // Act + final result = await repository.getFrequency( + period: FrequencyPeriod.month, + offset: 0, + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getFrequency(period: 'month', offset: 0)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getFrequency(period: 'month', offset: 0)).thenThrow(exception); + + // Act + final result = await repository.getFrequency( + period: FrequencyPeriod.month, + offset: 0, + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getFrequency(period: 'month', offset: 0)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getExercises()', () { + test('returns success(items) when api succeeds', () async { + // Arrange + when(apiClient.getExercises()).thenAnswer((_) async => createProfileExercisesResponseDto()); + + // Act + final result = await repository.getExercises(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, hasLength(1)); + expect(result.success!.first.id, testVolumeExerciseId); + expect(result.success!.first.name, testVolumeExerciseTitle); + + verify(apiClient.getExercises()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics/exercises', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getExercises()).thenThrow(exception); + + // Act + final result = await repository.getExercises(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getExercises()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getExercises()).thenThrow(exception); + + // Act + final result = await repository.getExercises(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getExercises()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getWorkouts()', () { + test('returns success(items) when api succeeds', () async { + // Arrange + when(apiClient.getWorkouts()).thenAnswer((_) async => createProfileWorkoutsResponseDto()); + + // Act + final result = await repository.getWorkouts(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, hasLength(1)); + expect(result.success!.first.id, testTrendWorkoutId); + expect(result.success!.first.title, testTrendWorkoutTitle); + + verify(apiClient.getWorkouts()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics/workouts', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getWorkouts()).thenThrow(exception); + + // Act + final result = await repository.getWorkouts(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getWorkouts()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getWorkouts()).thenThrow(exception); + + // Act + final result = await repository.getWorkouts(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getWorkouts()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + }); +} diff --git a/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart new file mode 100644 index 00000000..cf6eb1e5 --- /dev/null +++ b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart @@ -0,0 +1,437 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_history_tab.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_workout_option.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_statistics_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/profile_statistics_cubit.dart'; + +import '../../support/profile_statistics_dto_fixtures.dart'; +import 'profile_statistics_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileStatisticsRepository repository; + late ProfileStatisticsCubit cubit; + const failure = ProfileRequestFailure('error_message'); + + setUp(() { + repository = MockProfileStatisticsRepository(); + cubit = ProfileStatisticsCubit(repository); + provideDummy>( + const Success(testProfileStatisticsVolumeData), + ); + provideDummy>( + const Success(testProfileStatisticsTrendData), + ); + provideDummy>( + const Success(testProfileStatisticsFrequencyData), + ); + provideDummy, ProfileFailure>>( + const Success(testProfileStatisticsExercises), + ); + provideDummy, ProfileFailure>>( + const Success(testProfileStatisticsWorkouts), + ); + }); + + group('ProfileStatisticsCubit', () { + blocTest( + 'emits initial volume state when loadInitial succeeds', + setUp: () { + when(repository.getVolume()).thenAnswer( + (_) async => const Success(testProfileStatisticsVolumeData), + ); + when(repository.getExercises()).thenAnswer( + (_) async => const Success(testProfileStatisticsExercises), + ); + }, + build: () => cubit, + act: (cubit) => cubit.loadInitial(), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + ), + ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ], + verify: (_) { + verify(repository.getVolume()).called(1); + verify(repository.getExercises()).called(1); + }, + ); + + blocTest( + 'stores failure when initial volume load fails', + setUp: () { + when(repository.getVolume()).thenAnswer((_) async => const Failure(failure)); + when(repository.getExercises()).thenAnswer( + (_) async => const Success(testProfileStatisticsExercises), + ); + }, + build: () => cubit, + act: (cubit) => cubit.loadInitial(), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + ), + ProfileStatisticsState( + failure: failure, + ), + ], + verify: (_) { + verify(repository.getVolume()).called(1); + verify(repository.getExercises()).called(1); + }, + ); + + blocTest( + 'loads frequency mode on demand', + setUp: () => when( + repository.getFrequency(period: FrequencyPeriod.month, offset: 0), + ).thenAnswer((_) async => const Success(testProfileStatisticsFrequencyData)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectMode(ProfileStatisticsMode.frequency), + expect: () => const [ + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + frequencyData: testProfileStatisticsFrequencyData, + exerciseOptions: testProfileStatisticsExercises, + ), + ], + verify: (_) => verify( + repository.getFrequency(period: FrequencyPeriod.month, offset: 0), + ).called(1), + ); + + blocTest( + 'stores failure when loading frequency mode fails', + setUp: () => when( + repository.getFrequency(period: FrequencyPeriod.month, offset: 0), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectMode(ProfileStatisticsMode.frequency), + expect: () => const [ + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + failure: failure, + ), + ], + verify: (_) => verify( + repository.getFrequency(period: FrequencyPeriod.month, offset: 0), + ).called(1), + ); + + blocTest( + 'loads trend mode and workout options on demand', + setUp: () { + when(repository.getTrend()).thenAnswer( + (_) async => const Success(testProfileStatisticsTrendData), + ); + when(repository.getWorkouts()).thenAnswer( + (_) async => const Success(testProfileStatisticsWorkouts), + ); + }, + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectMode(ProfileStatisticsMode.trend), + expect: () => const [ + ProfileStatisticsState( + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + selectedWorkoutId: 231, + volumeData: testProfileStatisticsVolumeData, + trendData: testProfileStatisticsTrendData, + exerciseOptions: testProfileStatisticsExercises, + workoutOptions: testProfileStatisticsWorkouts, + ), + ], + verify: (_) { + verify(repository.getTrend()).called(1); + verify(repository.getWorkouts()).called(1); + }, + ); + + blocTest( + 'reuses cached trend mode payload without reloading', + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + selectedWorkoutId: 231, + trendData: testProfileStatisticsTrendData, + workoutOptions: testProfileStatisticsWorkouts, + ), + act: (cubit) => cubit.selectMode(ProfileStatisticsMode.trend), + expect: () => const [ + ProfileStatisticsState( + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + selectedWorkoutId: 231, + volumeData: testProfileStatisticsVolumeData, + trendData: testProfileStatisticsTrendData, + exerciseOptions: testProfileStatisticsExercises, + workoutOptions: testProfileStatisticsWorkouts, + ), + ], + verify: (_) { + verifyNever(repository.getTrend()); + verifyNever(repository.getWorkouts()); + }, + ); + + blocTest( + 'stores failure when loading trend mode fails', + setUp: () { + when(repository.getTrend()).thenAnswer((_) async => const Failure(failure)); + when(repository.getWorkouts()).thenAnswer( + (_) async => const Success(testProfileStatisticsWorkouts), + ); + }, + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectMode(ProfileStatisticsMode.trend), + expect: () => const [ + ProfileStatisticsState( + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.trend, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + failure: failure, + ), + ], + verify: (_) { + verify(repository.getTrend()).called(1); + verify(repository.getWorkouts()).called(1); + }, + ); + + blocTest( + 'refreshes volume when selecting another exercise', + setUp: () => when( + repository.getVolume(exerciseId: 17, weekOffset: 0), + ).thenAnswer((_) async => const Success(testProfileStatisticsVolumeData)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 1, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectExercise(17), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + selectedExerciseId: 1, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ], + verify: (_) => verify( + repository.getVolume(exerciseId: 17, weekOffset: 0), + ).called(1), + ); + + blocTest( + 'stores failure when refreshing volume for another exercise fails', + setUp: () => when( + repository.getVolume(exerciseId: 17, weekOffset: 0), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 1, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.selectExercise(17), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + selectedExerciseId: 1, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + selectedExerciseId: 1, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + failure: failure, + ), + ], + verify: (_) => verify( + repository.getVolume(exerciseId: 17, weekOffset: 0), + ).called(1), + ); + + blocTest( + 'updates frequency period and resets offset', + setUp: () => when( + repository.getFrequency(period: FrequencyPeriod.year, offset: 0), + ).thenAnswer((_) async => const Success(testProfileStatisticsYearFrequencyData)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedFrequencyOffset: 3, + frequencyData: testProfileStatisticsFrequencyData, + ), + act: (cubit) => cubit.selectFrequencyPeriod(FrequencyPeriod.year), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.frequency, + selectedFrequencyOffset: 3, + frequencyData: testProfileStatisticsFrequencyData, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedFrequencyPeriod: FrequencyPeriod.year, + frequencyData: testProfileStatisticsYearFrequencyData, + ), + ], + verify: (_) => verify( + repository.getFrequency(period: FrequencyPeriod.year, offset: 0), + ).called(1), + ); + + blocTest( + 'stores failure when updating frequency period fails', + setUp: () => when( + repository.getFrequency(period: FrequencyPeriod.year, offset: 0), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedFrequencyOffset: 3, + frequencyData: testProfileStatisticsFrequencyData, + ), + act: (cubit) => cubit.selectFrequencyPeriod(FrequencyPeriod.year), + expect: () => const [ + ProfileStatisticsState( + isLoading: true, + mode: ProfileStatisticsMode.frequency, + selectedFrequencyOffset: 3, + frequencyData: testProfileStatisticsFrequencyData, + ), + ProfileStatisticsState( + mode: ProfileStatisticsMode.frequency, + selectedFrequencyOffset: 3, + frequencyData: testProfileStatisticsFrequencyData, + failure: failure, + ), + ], + verify: (_) => verify( + repository.getFrequency(period: FrequencyPeriod.year, offset: 0), + ).called(1), + ); + + blocTest( + 'stores history snapshot and switches history tab', + build: () => cubit, + act: (cubit) { + cubit.setHistorySnapshot(testProfileStatisticsHistorySnapshot); + cubit.selectHistoryTab(ProfileHistoryTab.tests); + }, + expect: () => const [ + ProfileStatisticsState( + historySnapshot: testProfileStatisticsHistorySnapshot, + ), + ProfileStatisticsState( + selectedHistoryTab: ProfileHistoryTab.tests, + historySnapshot: testProfileStatisticsHistorySnapshot, + ), + ], + ); + }); +} diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart index 412b29e7..1f92b670 100644 --- a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -5,6 +5,7 @@ import 'package:mockito/mockito.dart'; import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; import 'package:moveup_flutter/core/result/result.dart'; import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; import 'package:moveup_flutter/features/profile/presentation/cubits/profile_user_cubit.dart'; @@ -33,12 +34,20 @@ void main() { repository = MockProfileRepository(); cubit = ProfileUserCubit(repository, seedUser: seedUser); provideDummy>(const Success(seedUser)); + provideDummy>( + Success(createProfileStatsHistorySnapshot()), + ); }); group('ProfileUserCubit', () { blocTest( 'emits loading and refreshed user when refresh succeeds', - setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)), + setUp: () { + when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)); + when(repository.getStatsHistorySnapshot()).thenAnswer( + (_) async => Success(createProfileStatsHistorySnapshot()), + ); + }, build: () => cubit, act: (cubit) => cubit.refresh(), expect: () => const [ @@ -48,14 +57,41 @@ void main() { ), ProfileUserState( user: updatedUser, + historySnapshot: ProfileStatsHistorySnapshot( + activeSubscription: ProfileActiveSubscriptionSnapshot( + id: testProfileSubscriptionId, + name: testProfileSubscriptionName, + price: testProfileSubscriptionPrice, + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + latestWorkout: ProfileLatestWorkoutSnapshot( + id: testProfileWorkoutHistoryId, + title: testProfileWorkoutTitle, + completedAt: testProfileWorkoutCompletedAt, + ), + latestTest: ProfileLatestTestSnapshot( + attemptId: testProfileTestAttemptId, + title: testProfileTestTitle, + completedAt: testProfileTestCompletedAt, + ), + ), ), ], - verify: (_) => verify(repository.getUser()).called(1), + verify: (_) { + verify(repository.getUser()).called(1); + verify(repository.getStatsHistorySnapshot()).called(1); + }, ); blocTest( 'emits loading only once when refresh is called twice in progress', - setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)), + setUp: () { + when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)); + when(repository.getStatsHistorySnapshot()).thenAnswer( + (_) async => Success(createProfileStatsHistorySnapshot()), + ); + }, build: () => cubit, act: (cubit) { cubit.refresh(); @@ -68,9 +104,31 @@ void main() { ), ProfileUserState( user: updatedUser, + historySnapshot: ProfileStatsHistorySnapshot( + activeSubscription: ProfileActiveSubscriptionSnapshot( + id: testProfileSubscriptionId, + name: testProfileSubscriptionName, + price: testProfileSubscriptionPrice, + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + latestWorkout: ProfileLatestWorkoutSnapshot( + id: testProfileWorkoutHistoryId, + title: testProfileWorkoutTitle, + completedAt: testProfileWorkoutCompletedAt, + ), + latestTest: ProfileLatestTestSnapshot( + attemptId: testProfileTestAttemptId, + title: testProfileTestTitle, + completedAt: testProfileTestCompletedAt, + ), + ), ), ], - verify: (_) => verify(repository.getUser()).called(1), + verify: (_) { + verify(repository.getUser()).called(1); + verify(repository.getStatsHistorySnapshot()).called(1); + }, ); blocTest( diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart index b607b5a3..e5266fe4 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -1,8 +1,12 @@ import 'package:dio/dio.dart'; +import 'package:moveup_flutter/features/profile/data/dto/active_profile_subscription_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/profile_test_history_item_dto.dart'; import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_user_data_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_user_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_user_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/profile_workout_history_item_dto.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; const testProfileUserId = 1; const testProfileUserName = 'name'; @@ -10,6 +14,19 @@ const testProfileUserEmail = 'tests@mail.com'; const testProfileUserAvatar = 'avatar.jpg'; const testProfileUserCreatedAt = '2026-01-01T10:00:00.000000Z'; const testProfileUserEmailVerified = true; +const testProfileSubscriptionId = 21; +const testProfileSubscriptionName = '3 месяца'; +const testProfileSubscriptionPrice = '1400.00'; +const testProfileSubscriptionStartDate = '2026-03-15'; +const testProfileSubscriptionEndDate = '2026-06-13'; +const testProfileWorkoutHistoryId = 101; +const testProfileWorkoutId = 5; +const testProfileWorkoutTitle = 'Утренняя зарядка'; +const testProfileWorkoutCompletedAt = '2026-03-15 10:30:00'; +const testProfileTestAttemptId = 3; +const testProfileTestId = 2; +const testProfileTestTitle = 'Базовый тест'; +const testProfileTestCompletedAt = '2026-03-14 15:20:00'; /// Test fixture for a shared authenticated [User]. User createProfileUser({ @@ -44,12 +61,123 @@ ProfileUserDto createProfileUserDto({ /// Test fixture for [ProfileUserResponseDto]. ProfileUserResponseDto createProfileUserResponseDto({ ProfileUserDto? user, + ProfileSubscriptionsDto? subscriptions, + ProfileWorkoutsDto? workouts, + ProfileTestsDto? tests, }) => ProfileUserResponseDto( data: ProfileUserDataDto( user: user ?? createProfileUserDto(), + subscriptions: subscriptions, + workouts: workouts, + tests: tests, ), ); +/// Test fixture for [ProfileSubscriptionsDto]. +ProfileSubscriptionsDto createProfileSubscriptionsDto({ + ActiveProfileSubscriptionDto? active, +}) => ProfileSubscriptionsDto( + active: active ?? createActiveProfileSubscriptionDto(), +); + +/// Test fixture for [ActiveProfileSubscriptionDto]. +ActiveProfileSubscriptionDto createActiveProfileSubscriptionDto({ + int id = testProfileSubscriptionId, + String name = testProfileSubscriptionName, + String price = testProfileSubscriptionPrice, + String startDate = testProfileSubscriptionStartDate, + String endDate = testProfileSubscriptionEndDate, + double? daysLeft = 89.38, +}) => ActiveProfileSubscriptionDto( + id: id, + name: name, + price: price, + startDate: startDate, + endDate: endDate, + daysLeft: daysLeft, +); + +/// Test fixture for [ProfileWorkoutsDto]. +ProfileWorkoutsDto createProfileWorkoutsDto({ + List? history, +}) => ProfileWorkoutsDto( + history: history ?? [createProfileWorkoutHistoryItemDto()], +); + +/// Test fixture for [ProfileWorkoutHistoryItemDto]. +ProfileWorkoutHistoryItemDto createProfileWorkoutHistoryItemDto({ + int id = testProfileWorkoutHistoryId, + int workoutId = testProfileWorkoutId, + String title = testProfileWorkoutTitle, + String completedAt = testProfileWorkoutCompletedAt, + int? durationMinutes = 45, +}) => ProfileWorkoutHistoryItemDto( + id: id, + workout: ProfileWorkoutHistoryWorkoutDto( + id: workoutId, + title: title, + ), + completedAt: completedAt, + durationMinutes: durationMinutes, +); + +/// Test fixture for [ProfileTestsDto]. +ProfileTestsDto createProfileTestsDto({ + List? history, +}) => ProfileTestsDto( + history: history ?? [createProfileTestHistoryItemDto()], +); + +/// Test fixture for [ProfileTestHistoryItemDto]. +ProfileTestHistoryItemDto createProfileTestHistoryItemDto({ + int attemptId = testProfileTestAttemptId, + int testingId = testProfileTestId, + String title = testProfileTestTitle, + String completedAt = testProfileTestCompletedAt, + int? pulse = 120, + int? exercisesCount = 5, +}) => ProfileTestHistoryItemDto( + attemptId: attemptId, + testing: ProfileTestHistoryTestingDto( + id: testingId, + title: title, + ), + completedAt: completedAt, + pulse: pulse, + exercisesCount: exercisesCount, +); + +/// Test fixture for [ProfileStatsHistorySnapshot]. +ProfileStatsHistorySnapshot createProfileStatsHistorySnapshot({ + ProfileActiveSubscriptionSnapshot? activeSubscription, + ProfileLatestWorkoutSnapshot? latestWorkout, + ProfileLatestTestSnapshot? latestTest, +}) => ProfileStatsHistorySnapshot( + activeSubscription: + activeSubscription ?? + const ProfileActiveSubscriptionSnapshot( + id: testProfileSubscriptionId, + name: testProfileSubscriptionName, + price: testProfileSubscriptionPrice, + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + latestWorkout: + latestWorkout ?? + const ProfileLatestWorkoutSnapshot( + id: testProfileWorkoutHistoryId, + title: testProfileWorkoutTitle, + completedAt: testProfileWorkoutCompletedAt, + ), + latestTest: + latestTest ?? + const ProfileLatestTestSnapshot( + attemptId: testProfileTestAttemptId, + title: testProfileTestTitle, + completedAt: testProfileTestCompletedAt, + ), +); + /// Test fixture for Dio bad response exception. DioException createProfileDioBadResponseException({ required String path, diff --git a/test/features/profile/support/profile_statistics_dto_fixtures.dart b/test/features/profile/support/profile_statistics_dto_fixtures.dart new file mode 100644 index 00000000..bb7f612d --- /dev/null +++ b/test/features/profile/support/profile_statistics_dto_fixtures.dart @@ -0,0 +1,386 @@ +import 'package:dio/dio.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/frequency_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/profile_exercises_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/profile_workouts_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/trend_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/volume_response_dto.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_workout_option.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/volume_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; + +const testVolumeExerciseId = 17; +const testVolumeExerciseTitle = 'Скручивания на пресс'; +const testVolumeAverageScorePercent = 66; +const testVolumeAverageScoreLabel = 'Нормально'; +const testVolumePeriodStart = '2026-03-16'; +const testVolumePeriodEnd = '2026-03-22'; +const testVolumePeriodLabel = 'Неделя 4'; +const testVolumeWeekOffset = 0; +const testTrendWorkoutId = 231; +const testTrendWorkoutTitle = 'Силовая: Грудь + трицепс'; +const testTrendWorkoutCompletedFormatted = '18.03.2026 08:32'; +const testFrequencyLabel = 'Текущий месяц'; + +const testProfileStatisticsVolumeData = VolumeStatisticsData( + hasData: true, + exerciseId: 17, + title: 'Скручивания на пресс', + averageScorePercent: 66, + averageScoreLabel: 'Нормально', + period: VolumePeriodData( + start: '2026-03-16', + end: '2026-03-22', + label: 'Неделя 4', + weekOffset: 0, + canGoPrevious: true, + canGoNext: false, + ), + chart: [ + VolumeChartBarData( + label: 'Пн', + value: 3500, + date: '2026-03-16', + ), + ], +); + +const testProfileStatisticsExercises = [ + ProfileExerciseOption( + id: 17, + name: 'Скручивания на пресс', + lastUsedFormatted: '19.03.2026', + ), +]; + +const testProfileStatisticsTrendData = TrendStatisticsData( + hasData: true, + workoutId: 231, + title: 'Силовая: Грудь + трицепс', + completedAtFormatted: '18.03.2026 08:32', + averageScorePercent: 100, + averageScoreLabel: 'Отлично', + exercises: [ + TrendExerciseData( + exerciseName: 'Жим штанги лежа', + scorePercent: 100, + scoreLabel: 'Отлично', + reaction: 'good', + weightUsed: '60.0', + ), + ], +); + +const testProfileStatisticsWorkouts = [ + ProfileWorkoutOption( + id: 231, + title: 'Силовая: Грудь + трицепс', + completedAtFormatted: '18.03.2026', + ), +]; + +const testProfileStatisticsFrequencyData = FrequencyStatisticsData( + hasData: true, + period: FrequencyPeriod.month, + offset: 0, + label: 'Текущий месяц', + averagePerWeek: 2.3, + chart: [ + FrequencyChartBarData( + label: 'Нед 1', + shortLabel: '1', + count: 1, + goal: 4, + ), + ], +); + +const testProfileStatisticsYearFrequencyData = FrequencyStatisticsData( + hasData: true, + period: FrequencyPeriod.year, + offset: 0, + label: 'Текущий год', + averagePerWeek: 2.3, + chart: [ + FrequencyChartBarData( + label: 'Янв', + shortLabel: 'Я', + count: 1, + goal: 4, + ), + ], +); + +const testProfileStatisticsHistorySnapshot = ProfileStatsHistorySnapshot( + activeSubscription: ProfileActiveSubscriptionSnapshot( + id: 21, + name: '3 месяца', + price: '1400.00', + startDate: '2026-03-15', + endDate: '2026-06-13', + ), + latestWorkout: ProfileLatestWorkoutSnapshot( + id: 101, + title: 'Утренняя зарядка', + completedAt: '2026-03-15 10:30:00', + ), + latestTest: ProfileLatestTestSnapshot( + attemptId: 3, + title: 'Базовый тест', + completedAt: '2026-03-14 15:20:00', + ), +); + +/// Test fixture for [VolumeResponseDto]. +VolumeResponseDto createVolumeResponseDto({ + VolumeStatisticsDto? data, +}) => VolumeResponseDto( + data: data ?? createVolumeStatisticsDto(), +); + +/// Test fixture for [VolumeStatisticsDto]. +VolumeStatisticsDto createVolumeStatisticsDto({ + bool hasData = true, + ProfileExerciseInfoDto? exercise, + int? averageScorePercent = testVolumeAverageScorePercent, + String? averageScoreLabel = testVolumeAverageScoreLabel, + VolumePeriodDto? period, + List? chart, +}) => VolumeStatisticsDto( + hasData: hasData, + exercise: exercise ?? createProfileExerciseInfoDto(), + averageScore: 66.7, + averageScorePercent: averageScorePercent, + averageScoreLabel: averageScoreLabel, + period: period ?? createVolumePeriodDto(), + summary: VolumeSummaryDto( + totalVolume: 5225, + workoutCount: 3, + averageVolumePerWorkout: 1741.7, + ), + chart: + chart ?? + [ + VolumeChartItemDto(name: 'Пн', totalVolume: 3500, date: '2026-03-16'), + VolumeChartItemDto(name: 'Вт', totalVolume: 1500, date: '2026-03-17'), + ], +); + +/// Test fixture for [ProfileExerciseInfoDto]. +ProfileExerciseInfoDto createProfileExerciseInfoDto({ + int id = testVolumeExerciseId, + String title = testVolumeExerciseTitle, + String muscleGroup = 'Пресс', +}) => ProfileExerciseInfoDto( + id: id, + title: title, + muscleGroup: muscleGroup, +); + +/// Test fixture for [VolumePeriodDto]. +VolumePeriodDto createVolumePeriodDto({ + String start = testVolumePeriodStart, + String end = testVolumePeriodEnd, + String label = testVolumePeriodLabel, + int? weekOffset = testVolumeWeekOffset, + bool canGoPrevious = true, + bool canGoNext = false, +}) => VolumePeriodDto( + start: start, + end: end, + label: label, + weekNumber: 4, + weekOffset: weekOffset, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, +); + +/// Test fixture for [TrendResponseDto]. +TrendResponseDto createTrendResponseDto({ + TrendStatisticsDto? data, +}) => TrendResponseDto( + data: data ?? createTrendStatisticsDto(), +); + +/// Test fixture for [TrendStatisticsDto]. +TrendStatisticsDto createTrendStatisticsDto({ + bool hasData = true, + TrendWorkoutInfoDto? workout, + int? averageScorePercent = 100, + String? averageScoreLabel = 'Отлично', + List? chart, +}) => TrendStatisticsDto( + hasData: hasData, + workout: workout ?? createTrendWorkoutInfoDto(), + averageScore: 100, + averageScorePercent: averageScorePercent, + averageScoreLabel: averageScoreLabel, + chart: + chart ?? + [ + TrendChartItemDto( + exerciseNumber: 1, + exerciseId: 1, + exerciseName: 'Жим штанги лежа', + reaction: 'good', + score: 100, + scorePercent: 100, + scoreLabel: 'Отлично', + weightUsed: '60.0', + setsCompleted: 3, + repsCompleted: 10, + setsPlanned: 3, + repsPlanned: 10, + ), + ], + availableWorkouts: [ + AvailableWorkoutDto( + id: testTrendWorkoutId, + title: testTrendWorkoutTitle, + date: '18.03.2026', + isCurrent: true, + ), + ], +); + +/// Test fixture for [TrendWorkoutInfoDto]. +TrendWorkoutInfoDto createTrendWorkoutInfoDto({ + int id = testTrendWorkoutId, + int workoutId = 6, + String title = testTrendWorkoutTitle, + String completedAt = '2026-03-18', + String completedAtFormatted = testTrendWorkoutCompletedFormatted, +}) => TrendWorkoutInfoDto( + id: id, + workoutId: workoutId, + title: title, + completedAt: completedAt, + completedAtFormatted: completedAtFormatted, + durationMinutes: 42, +); + +/// Test fixture for [FrequencyResponseDto]. +FrequencyResponseDto createFrequencyResponseDto({ + FrequencyStatisticsDto? data, +}) => FrequencyResponseDto( + data: data ?? createFrequencyStatisticsDto(), +); + +/// Test fixture for [FrequencyStatisticsDto]. +FrequencyStatisticsDto createFrequencyStatisticsDto({ + bool hasData = true, + FrequencyPeriodInfoDto? periodInfo, + bool includePeriodInfo = true, + List? chart, + double? averagePerWeek = 2.3, +}) => FrequencyStatisticsDto( + hasData: hasData, + periodInfo: includePeriodInfo + ? periodInfo ?? + FrequencyPeriodInfoDto( + type: 'month', + offset: 0, + label: testFrequencyLabel, + itemsCount: 4, + ) + : null, + summary: FrequencySummaryDto( + totalWorkouts: 10, + averagePerWeek: averagePerWeek, + currentStreak: 0, + longestStreak: 1, + weeklyGoal: 4, + ), + chart: + chart ?? + [ + FrequencyChartItemDto( + dayIndex: null, + dayNumber: null, + weekIndex: 0, + weekNumber: 1, + label: 'Нед 1', + shortLabel: '1', + startDate: '2026-02-23', + endDate: '2026-03-01', + count: 1, + goal: 4, + ), + FrequencyChartItemDto( + dayIndex: null, + dayNumber: null, + weekIndex: 1, + weekNumber: 2, + label: 'Нед 2', + shortLabel: '2', + startDate: '2026-03-02', + endDate: '2026-03-08', + count: 3, + goal: 4, + ), + ], +); + +/// Test fixture for [ProfileExercisesResponseDto]. +ProfileExercisesResponseDto createProfileExercisesResponseDto({ + List? data, +}) => ProfileExercisesResponseDto( + data: + data ?? + [ + ProfileExerciseItemDto( + id: testVolumeExerciseId, + name: testVolumeExerciseTitle, + lastUsed: '2026-03-19', + lastUsedFormatted: '19.03.2026', + ), + ], +); + +/// Test fixture for [ProfileWorkoutsResponseDto]. +ProfileWorkoutsResponseDto createProfileWorkoutsResponseDto({ + List? data, +}) => ProfileWorkoutsResponseDto( + data: + data ?? + [ + ProfileWorkoutItemDto( + id: testTrendWorkoutId, + workoutId: 6, + title: testTrendWorkoutTitle, + completedAt: '2026-03-18', + completedAtFormatted: '18.03.2026', + durationMinutes: 42, + ), + ], +); + +/// Test fixture for Dio bad response exception used in statistics tests. +DioException createProfileStatisticsDioBadResponseException({ + required String path, + required int statusCode, + required String code, + String message = 'error_message', + Map>? errors, +}) { + final requestOptions = RequestOptions(path: path); + final data = { + 'code': code, + 'message': message, + }; + if (errors != null) { + data['errors'] = errors; + } + return DioException( + requestOptions: requestOptions, + type: DioExceptionType.badResponse, + response: Response>( + requestOptions: requestOptions, + statusCode: statusCode, + data: data, + ), + ); +} From d7fcc6e2b1cc335b03cc03a898308ffefb4b0026 Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:55:42 +0700 Subject: [PATCH 04/13] feat(profile): add current phase section to profile (#54) * feat(profile): extend profile bootstrap with phase snapshot * test(profile): add phase bootstrap repository coverage * feat(profile): add current phase summary to profile statistics flow * test(profile): add current phase summary coverage * feat(profile-ui): add current phase section UI * docs: update CHANGELOG.md * fix(profile-ui): polish ui and decline current phase rounded training count in russian * chore(profile): align phase dto nullability and cache cleanup --- CHANGELOG.md | 1 + lib/core/constants/app_strings.dart | 20 ++ .../data/dto/profile_user_data_dto.dart | 45 +++ ...file_statistics_overview_response_dto.dart | 67 ++++ .../profile_phase_snapshot_mapper.dart | 11 + .../mappers/profile_statistics_mapper.dart | 11 + .../remote/profile_statistics_api_client.dart | 5 + .../repositories/profile_repository_impl.dart | 30 ++ .../profile_statistics_repository_impl.dart | 17 + .../entities/profile_phase_snapshot.dart | 19 ++ .../profile_current_phase_summary.dart | 19 ++ .../repositories/profile_repository.dart | 4 + .../profile_statistics_repository.dart | 4 + .../cubits/profile_statistics_cubit.dart | 67 +++- .../cubits/profile_statistics_state.dart | 3 + .../cubits/profile_user_cubit.dart | 8 + .../cubits/profile_user_state.dart | 1 + .../presentation/pages/profile_page.dart | 3 + .../widgets/current_phase_section_widget.dart | 306 ++++++++++++++++++ .../profile_repository_impl_test.dart | 115 +++++++ ...ofile_statistics_repository_impl_test.dart | 58 ++++ .../cubits/profile_statistics_cubit_test.dart | 89 +++++ .../cubits/profile_user_cubit_test.dart | 20 ++ .../profile/support/profile_dto_fixtures.dart | 33 ++ .../profile_statistics_dto_fixtures.dart | 39 +++ 25 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 lib/features/profile/data/dto/stats/profile_statistics_overview_response_dto.dart create mode 100644 lib/features/profile/data/mappers/profile_phase_snapshot_mapper.dart create mode 100644 lib/features/profile/domain/entities/profile_phase_snapshot.dart create mode 100644 lib/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart create mode 100644 lib/features/profile/presentation/widgets/current_phase_section_widget.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 895e727e..6f0f69bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. - Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout. - Profile statistics section for the authenticated `/profile` tab, including dedicated statistics API client/repository, focused `/profile` history snapshot mapping, statistics Cubit/state flow, chart widgets, selectors, history dialog, and widget coverage for the integrated UI. +- Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice. ### Changed diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index 071c3f96..afbc0db8 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -262,6 +262,10 @@ abstract final class AppStrings { static const profileUpdateFailed = 'Не удалось обновить профиль. Попробуйте снова'; static const profileChangePasswordFailed = 'Не удалось сменить пароль. Попробуйте снова'; static const profileImagePickFailed = 'Не удалось выбрать изображение. Попробуйте снова'; + static const profileCurrentPhaseTitle = 'Текущая фаза'; + static const profileCurrentPhaseRecommendation = 'Вам рекомендуется тренироваться в неделю'; + static const profileCurrentPhaseEmpty = 'У вас пока нет активной фазы'; + static const profileCurrentPhaseLoadFailed = 'Не удалось загрузить текущую фазу'; static const profileStatsTitle = 'Статистика тренировок пользователя'; static const profileStatsHistoryButton = 'История'; static const profileStatsVolumeMode = 'Объём'; @@ -293,6 +297,22 @@ abstract final class AppStrings { static String profileStatsAveragePerWeek(String value) => 'В среднем: $value / нед'; + static String profileCurrentPhaseTrainingsPerWeek(int value) => + 'Вы тренируетесь $value ${_profileCurrentPhaseTimesLabel(value)} в неделю.'; + + static String _profileCurrentPhaseTimesLabel(int value) { + final mod100 = value % 100; + if (mod100 >= 11 && mod100 <= 14) { + return 'раз'; + } + + return switch (value % 10) { + 1 => 'раз', + 2 || 3 || 4 => 'раза', + _ => 'раз', + }; + } + /// Builds the increase-adjustment message for a new absolute weight value. static String workoutExecutionAdjustmentIncrease(String weight) => 'На следующем подходе увеличьте вес до $weight $workoutExecutionWeightHint'; diff --git a/lib/features/profile/data/dto/profile_user_data_dto.dart b/lib/features/profile/data/dto/profile_user_data_dto.dart index 4637925f..0b2f87f4 100644 --- a/lib/features/profile/data/dto/profile_user_data_dto.dart +++ b/lib/features/profile/data/dto/profile_user_data_dto.dart @@ -22,15 +22,60 @@ class ProfileUserDataDto { /// Test history snapshot for profile statistics history. final ProfileTestsDto? tests; + /// Phase snapshot for the current phase section. + final ProfilePhaseDto? phase; + /// Creates an instance of [ProfileUserDataDto]. ProfileUserDataDto({ required this.user, this.subscriptions, this.workouts, this.tests, + this.phase, }); /// Creates a [ProfileUserDataDto] from JSON. factory ProfileUserDataDto.fromJson(Map json) => _$ProfileUserDataDtoFromJson(json); } + +/// DTO with focused phase payload from `/profile`. +@JsonSerializable(createToJson: false) +class ProfilePhaseDto { + /// Whether the authenticated user has active phase progress. + @JsonKey(name: 'has_progress') + final bool hasProgress; + + /// Current phase snapshot. + @JsonKey(name: 'current_phase') + final ProfileCurrentPhaseDto? currentPhase; + + /// Creates an instance of [ProfilePhaseDto]. + ProfilePhaseDto({ + required this.hasProgress, + this.currentPhase, + }); + + /// Creates a [ProfilePhaseDto] from JSON. + factory ProfilePhaseDto.fromJson(Map json) => _$ProfilePhaseDtoFromJson(json); +} + +/// DTO with current phase name returned by `/profile`. +@JsonSerializable(createToJson: false) +class ProfileCurrentPhaseDto { + /// Current phase identifier. + final int id; + + /// Current phase title. + final String name; + + /// Creates an instance of [ProfileCurrentPhaseDto]. + ProfileCurrentPhaseDto({ + required this.id, + required this.name, + }); + + /// Creates a [ProfileCurrentPhaseDto] from JSON. + factory ProfileCurrentPhaseDto.fromJson(Map json) => + _$ProfileCurrentPhaseDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/stats/profile_statistics_overview_response_dto.dart b/lib/features/profile/data/dto/stats/profile_statistics_overview_response_dto.dart new file mode 100644 index 00000000..8294ed42 --- /dev/null +++ b/lib/features/profile/data/dto/stats/profile_statistics_overview_response_dto.dart @@ -0,0 +1,67 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_statistics_overview_response_dto.g.dart'; + +/// DTO for the aggregate profile statistics overview response. +@JsonSerializable(createToJson: false) +class ProfileStatisticsOverviewResponseDto { + /// Nested overview payload. + final ProfileStatisticsOverviewDataDto data; + + /// Creates an instance of [ProfileStatisticsOverviewResponseDto]. + ProfileStatisticsOverviewResponseDto({required this.data}); + + /// Creates a [ProfileStatisticsOverviewResponseDto] from JSON. + factory ProfileStatisticsOverviewResponseDto.fromJson(Map json) => + _$ProfileStatisticsOverviewResponseDtoFromJson(json); +} + +/// DTO with focused aggregate statistics subset for the current phase section. +@JsonSerializable(createToJson: false) +class ProfileStatisticsOverviewDataDto { + /// Frequency overview subset. + final ProfileStatisticsOverviewFrequencyDto? frequency; + + /// Creates an instance of [ProfileStatisticsOverviewDataDto]. + ProfileStatisticsOverviewDataDto({this.frequency}); + + /// Creates a [ProfileStatisticsOverviewDataDto] from JSON. + factory ProfileStatisticsOverviewDataDto.fromJson(Map json) => + _$ProfileStatisticsOverviewDataDtoFromJson(json); +} + +/// DTO with frequency subset from the aggregate statistics response. +@JsonSerializable(createToJson: false) +class ProfileStatisticsOverviewFrequencyDto { + /// Frequency summary subset. + final ProfileStatisticsOverviewFrequencySummaryDto? summary; + + /// Creates an instance of [ProfileStatisticsOverviewFrequencyDto]. + ProfileStatisticsOverviewFrequencyDto({required this.summary}); + + /// Creates a [ProfileStatisticsOverviewFrequencyDto] from JSON. + factory ProfileStatisticsOverviewFrequencyDto.fromJson(Map json) => + _$ProfileStatisticsOverviewFrequencyDtoFromJson(json); +} + +/// DTO with current phase frequency numbers for the profile section. +@JsonSerializable(createToJson: false) +class ProfileStatisticsOverviewFrequencySummaryDto { + /// Average trainings per week. + @JsonKey(name: 'average_per_week') + final double averagePerWeek; + + /// Recommended weekly goal. + @JsonKey(name: 'weekly_goal') + final int weeklyGoal; + + /// Creates an instance of [ProfileStatisticsOverviewFrequencySummaryDto]. + ProfileStatisticsOverviewFrequencySummaryDto({ + required this.averagePerWeek, + required this.weeklyGoal, + }); + + /// Creates a [ProfileStatisticsOverviewFrequencySummaryDto] from JSON. + factory ProfileStatisticsOverviewFrequencySummaryDto.fromJson(Map json) => + _$ProfileStatisticsOverviewFrequencySummaryDtoFromJson(json); +} diff --git a/lib/features/profile/data/mappers/profile_phase_snapshot_mapper.dart b/lib/features/profile/data/mappers/profile_phase_snapshot_mapper.dart new file mode 100644 index 00000000..88b82a65 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_phase_snapshot_mapper.dart @@ -0,0 +1,11 @@ +import '../../domain/entities/profile_phase_snapshot.dart'; +import '../dto/profile_user_data_dto.dart'; + +/// Maps aggregate `/profile` DTO subset to phase snapshot entities. +extension ProfilePhaseSnapshotMapper on ProfileUserDataDto { + /// Returns a focused phase snapshot for the profile current phase UI. + ProfilePhaseSnapshot toPhaseSnapshot() => ProfilePhaseSnapshot( + hasProgress: phase?.hasProgress ?? false, + currentPhaseName: phase?.currentPhase?.name, + ); +} diff --git a/lib/features/profile/data/mappers/profile_statistics_mapper.dart b/lib/features/profile/data/mappers/profile_statistics_mapper.dart index 632e67cf..fead6a9b 100644 --- a/lib/features/profile/data/mappers/profile_statistics_mapper.dart +++ b/lib/features/profile/data/mappers/profile_statistics_mapper.dart @@ -1,15 +1,26 @@ import '../../domain/entities/profile_statistics/frequency_period.dart'; import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_current_phase_summary.dart'; import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; import '../../domain/entities/profile_statistics/profile_workout_option.dart'; import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; import '../../domain/entities/profile_statistics/volume_statistics_data.dart'; import '../dto/stats/frequency_response_dto.dart'; import '../dto/stats/profile_exercises_response_dto.dart'; +import '../dto/stats/profile_statistics_overview_response_dto.dart'; import '../dto/stats/profile_workouts_response_dto.dart'; import '../dto/stats/trend_response_dto.dart'; import '../dto/stats/volume_response_dto.dart'; +/// Maps profile statistics DTOs into domain entities. +extension ProfileStatisticsOverviewMapper on ProfileStatisticsOverviewDataDto { + /// Converts aggregate statistics overview into current phase summary data. + ProfileCurrentPhaseSummary toCurrentPhaseSummary() => ProfileCurrentPhaseSummary( + averagePerWeek: frequency?.summary?.averagePerWeek ?? 0.0, + weeklyGoal: frequency?.summary?.weeklyGoal ?? 0, + ); +} + /// Maps profile statistics DTOs into domain entities. extension VolumeStatisticsMapper on VolumeStatisticsDto { /// Converts volume statistics DTO into [VolumeStatisticsData]. diff --git a/lib/features/profile/data/remote/profile_statistics_api_client.dart b/lib/features/profile/data/remote/profile_statistics_api_client.dart index 940a1d73..6ae212d2 100644 --- a/lib/features/profile/data/remote/profile_statistics_api_client.dart +++ b/lib/features/profile/data/remote/profile_statistics_api_client.dart @@ -4,6 +4,7 @@ import 'package:retrofit/retrofit.dart'; import '../../../../core/network/api_paths.dart'; import '../dto/stats/frequency_response_dto.dart'; import '../dto/stats/profile_exercises_response_dto.dart'; +import '../dto/stats/profile_statistics_overview_response_dto.dart'; import '../dto/stats/profile_workouts_response_dto.dart'; import '../dto/stats/trend_response_dto.dart'; import '../dto/stats/volume_response_dto.dart'; @@ -19,6 +20,10 @@ abstract class ProfileStatisticsApiClient { String? baseUrl, }) = _ProfileStatisticsApiClient; + /// Returns aggregate profile statistics overview. + @GET(ApiPaths.profileStatistics) + Future getOverview(); + /// Returns volume statistics for the authenticated profile. @GET(ApiPaths.profileStatisticsVolume) Future getVolume({ diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index cd746fa5..6fd15d4f 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -7,11 +7,13 @@ import '../../../../core/network/mappers/dio_exception_mapper.dart'; import '../../../../core/result/result.dart'; import '../../../../core/utils/logger/app_logger.dart'; import '../../../auth/domain/entities/user.dart'; +import '../../domain/entities/profile_phase_snapshot.dart'; import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; import '../dto/change_password_request_dto.dart'; import '../dto/update_profile_request_dto.dart'; import '../mappers/profile_failure_mapper.dart'; +import '../mappers/profile_phase_snapshot_mapper.dart'; import '../mappers/profile_history_snapshot_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -21,6 +23,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; + ProfilePhaseSnapshot? _cachedPhaseSnapshot; /// Creates an instance of [ProfileRepositoryImpl]. ProfileRepositoryImpl(this._logger, this._apiClient); @@ -30,6 +33,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { try { final response = await _apiClient.getProfile(); _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); + _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); return Result.success(response.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -53,6 +57,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { final response = await _apiClient.getProfile(); final snapshot = response.data.toStatsHistorySnapshot(); _cachedStatsHistorySnapshot = snapshot; + _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); return Result.success(snapshot); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -65,6 +70,30 @@ final class ProfileRepositoryImpl implements ProfileRepository { } } + @override + Future> getPhaseSnapshot() async { + final cachedPhaseSnapshot = _cachedPhaseSnapshot; + if (cachedPhaseSnapshot != null) { + return Result.success(cachedPhaseSnapshot); + } + + try { + final response = await _apiClient.getProfile(); + final snapshot = response.data.toPhaseSnapshot(); + _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); + _cachedPhaseSnapshot = snapshot; + return Result.success(snapshot); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetPhaseSnapshot failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + @override Future> updateUser({ required User currentUser, @@ -102,6 +131,7 @@ final class ProfileRepositoryImpl implements ProfileRepository { final refreshedResponse = await _apiClient.getProfile(); _cachedStatsHistorySnapshot = refreshedResponse.data.toStatsHistorySnapshot(); + _cachedPhaseSnapshot = refreshedResponse.data.toPhaseSnapshot(); return Result.success(refreshedResponse.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); diff --git a/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart b/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart index 91a6d573..1e3b977b 100644 --- a/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_statistics_repository_impl.dart @@ -6,6 +6,7 @@ import '../../../../core/result/result.dart'; import '../../../../core/utils/logger/app_logger.dart'; import '../../domain/entities/profile_statistics/frequency_period.dart'; import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_current_phase_summary.dart'; import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; import '../../domain/entities/profile_statistics/profile_workout_option.dart'; import '../../domain/entities/profile_statistics/trend_statistics_data.dart'; @@ -23,6 +24,22 @@ final class ProfileStatisticsRepositoryImpl implements ProfileStatisticsReposito /// Creates an instance of [ProfileStatisticsRepositoryImpl]. ProfileStatisticsRepositoryImpl(this._logger, this._apiClient); + @override + Future> getCurrentPhaseSummary() async { + try { + final response = await _apiClient.getOverview(); + return Result.success(response.data.toCurrentPhaseSummary()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetCurrentPhaseSummary failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + @override Future> getVolume({ int? exerciseId, diff --git a/lib/features/profile/domain/entities/profile_phase_snapshot.dart b/lib/features/profile/domain/entities/profile_phase_snapshot.dart new file mode 100644 index 00000000..fe6495af --- /dev/null +++ b/lib/features/profile/domain/entities/profile_phase_snapshot.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; + +/// Focused phase snapshot used by the profile current phase section. +final class ProfilePhaseSnapshot extends Equatable { + /// Whether the authenticated user has active phase progress. + final bool hasProgress; + + /// Current phase display name. + final String? currentPhaseName; + + /// Creates an instance of [ProfilePhaseSnapshot]. + const ProfilePhaseSnapshot({ + required this.hasProgress, + required this.currentPhaseName, + }); + + @override + List get props => [hasProgress, currentPhaseName]; +} diff --git a/lib/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart b/lib/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart new file mode 100644 index 00000000..0baf9d66 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; + +/// Focused frequency summary used by the profile current phase section. +final class ProfileCurrentPhaseSummary extends Equatable { + /// Average weekly training frequency. + final double averagePerWeek; + + /// Recommended weekly training goal. + final int weeklyGoal; + + /// Creates an instance of [ProfileCurrentPhaseSummary]. + const ProfileCurrentPhaseSummary({ + required this.averagePerWeek, + required this.weeklyGoal, + }); + + @override + List get props => [averagePerWeek, weeklyGoal]; +} diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index 8d210d18..dabd6d15 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -1,6 +1,7 @@ import '../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; +import '../entities/profile_phase_snapshot.dart'; import '../entities/profile_stats_history_snapshot.dart'; /// Repository interface for authenticated profile operations. @@ -11,6 +12,9 @@ abstract interface class ProfileRepository { /// Returns the current history snapshot for the statistics history modal. Future> getStatsHistorySnapshot(); + /// Returns the current phase snapshot for the current phase section. + Future> getPhaseSnapshot(); + /// Updates the current user profile and returns the canonical refreshed user payload. Future> updateUser({ required User currentUser, diff --git a/lib/features/profile/domain/repositories/profile_statistics_repository.dart b/lib/features/profile/domain/repositories/profile_statistics_repository.dart index 55225329..8384f40e 100644 --- a/lib/features/profile/domain/repositories/profile_statistics_repository.dart +++ b/lib/features/profile/domain/repositories/profile_statistics_repository.dart @@ -1,6 +1,7 @@ import '../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../core/result/result.dart'; import '../entities/profile_statistics/frequency_period.dart'; +import '../entities/profile_statistics/profile_current_phase_summary.dart'; import '../entities/profile_statistics/frequency_statistics_data.dart'; import '../entities/profile_statistics/profile_exercise_option.dart'; import '../entities/profile_statistics/profile_workout_option.dart'; @@ -9,6 +10,9 @@ import '../entities/profile_statistics/volume_statistics_data.dart'; /// Repository interface for profile statistics operations. abstract interface class ProfileStatisticsRepository { + /// Returns frequency summary used by the current phase section. + Future> getCurrentPhaseSummary(); + /// Returns volume statistics for the selected exercise and week offset. Future> getVolume({ int? exerciseId, diff --git a/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart index 53c68612..4efe207c 100644 --- a/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart @@ -5,6 +5,7 @@ import '../../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../../core/result/result.dart'; import '../../domain/entities/profile_statistics/frequency_period.dart'; import '../../domain/entities/profile_statistics/frequency_statistics_data.dart'; +import '../../domain/entities/profile_statistics/profile_current_phase_summary.dart'; import '../../domain/entities/profile_statistics/profile_exercise_option.dart'; import '../../domain/entities/profile_statistics/profile_history_tab.dart'; import '../../domain/entities/profile_statistics/profile_statistics_mode.dart'; @@ -28,13 +29,34 @@ final class ProfileStatisticsCubit extends Cubit { Future loadInitial() async { if (state.isLoading) return; - emit(state.copyWith(isLoading: true, failure: null)); + emit( + state.copyWith( + isLoading: true, + isLoadingCurrentPhaseSummary: true, + failure: null, + currentPhaseSummaryFailure: null, + ), + ); + + final volumeFuture = _repository.getVolume(); + final exercisesFuture = _repository.getExercises(); + final currentPhaseSummaryFuture = _repository.getCurrentPhaseSummary(); - final volumeResult = await _repository.getVolume(); - final exercisesResult = await _repository.getExercises(); + final volumeResult = await volumeFuture; + final exercisesResult = await exercisesFuture; + final currentPhaseSummaryResult = await currentPhaseSummaryFuture; if (isClosed) return; + final currentPhaseSummary = switch (currentPhaseSummaryResult) { + Success(data: final summary) => summary, + Failure() => state.currentPhaseSummary, + }; + final currentPhaseSummaryFailure = switch (currentPhaseSummaryResult) { + Success() => null, + Failure(:final error) => error, + }; + switch (volumeResult) { case Success(data: final volumeData): final exerciseOptions = switch (exercisesResult) { @@ -44,10 +66,13 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, + isLoadingCurrentPhaseSummary: false, mode: ProfileStatisticsMode.volume, selectedExerciseId: volumeData.exerciseId, + currentPhaseSummary: currentPhaseSummary, volumeData: volumeData, exerciseOptions: exerciseOptions, + currentPhaseSummaryFailure: currentPhaseSummaryFailure, failure: null, ), ); @@ -55,6 +80,9 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, + isLoadingCurrentPhaseSummary: false, + currentPhaseSummary: currentPhaseSummary, + currentPhaseSummaryFailure: currentPhaseSummaryFailure, failure: error, ), ); @@ -203,6 +231,39 @@ final class ProfileStatisticsCubit extends Cubit { } } + /// Reloads only the current phase summary data used by the profile phase section. + Future reloadCurrentPhaseSummary() async { + if (state.isLoadingCurrentPhaseSummary) return; + + emit( + state.copyWith( + isLoadingCurrentPhaseSummary: true, + currentPhaseSummaryFailure: null, + ), + ); + + final result = await _repository.getCurrentPhaseSummary(); + if (isClosed) return; + + switch (result) { + case Success(data: final summary): + emit( + state.copyWith( + isLoadingCurrentPhaseSummary: false, + currentPhaseSummary: summary, + currentPhaseSummaryFailure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoadingCurrentPhaseSummary: false, + currentPhaseSummaryFailure: error, + ), + ); + } + } + Future _loadVolume({ required int? exerciseId, required int weekOffset, diff --git a/lib/features/profile/presentation/cubits/profile_statistics_state.dart b/lib/features/profile/presentation/cubits/profile_statistics_state.dart index f91370cc..7bd7a07c 100644 --- a/lib/features/profile/presentation/cubits/profile_statistics_state.dart +++ b/lib/features/profile/presentation/cubits/profile_statistics_state.dart @@ -6,6 +6,7 @@ abstract class ProfileStatisticsState with _$ProfileStatisticsState { /// Creates an instance of [ProfileStatisticsState]. const factory ProfileStatisticsState({ @Default(false) bool isLoading, + @Default(false) bool isLoadingCurrentPhaseSummary, @Default(ProfileStatisticsMode.volume) ProfileStatisticsMode mode, @Default(ProfileHistoryTab.subscriptions) ProfileHistoryTab selectedHistoryTab, int? selectedExerciseId, @@ -13,11 +14,13 @@ abstract class ProfileStatisticsState with _$ProfileStatisticsState { @Default(FrequencyPeriod.month) FrequencyPeriod selectedFrequencyPeriod, @Default(0) int selectedFrequencyOffset, ProfileStatsHistorySnapshot? historySnapshot, + ProfileCurrentPhaseSummary? currentPhaseSummary, VolumeStatisticsData? volumeData, FrequencyStatisticsData? frequencyData, TrendStatisticsData? trendData, @Default([]) List exerciseOptions, @Default([]) List workoutOptions, + ProfileFailure? currentPhaseSummaryFailure, ProfileFailure? failure, }) = _ProfileStatisticsState; } diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart index ab83ab64..749a3524 100644 --- a/lib/features/profile/presentation/cubits/profile_user_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart @@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; +import '../../domain/entities/profile_phase_snapshot.dart'; import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; @@ -38,16 +39,23 @@ final class ProfileUserCubit extends Cubit { case Success(data: final user): final historyResult = await _repository.getStatsHistorySnapshot(); if (isClosed) return; + final phaseResult = await _repository.getPhaseSnapshot(); + if (isClosed) return; final historySnapshot = switch (historyResult) { Success(data: final snapshot) => snapshot, Failure() => state.historySnapshot, }; + final phaseSnapshot = switch (phaseResult) { + Success(data: final snapshot) => snapshot, + Failure() => state.phaseSnapshot, + }; emit( state.copyWith( isLoading: false, user: user, historySnapshot: historySnapshot, + phaseSnapshot: phaseSnapshot, failure: null, ), ); diff --git a/lib/features/profile/presentation/cubits/profile_user_state.dart b/lib/features/profile/presentation/cubits/profile_user_state.dart index 21bbcdee..ca0fb057 100644 --- a/lib/features/profile/presentation/cubits/profile_user_state.dart +++ b/lib/features/profile/presentation/cubits/profile_user_state.dart @@ -8,6 +8,7 @@ abstract class ProfileUserState with _$ProfileUserState { @Default(false) bool isLoading, User? user, ProfileStatsHistorySnapshot? historySnapshot, + ProfilePhaseSnapshot? phaseSnapshot, ProfileFailure? failure, }) = _ProfileUserState; } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 4d79ceeb..ff11fea4 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -17,6 +17,7 @@ import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; +import '../widgets/current_phase_section_widget.dart'; import '../widgets/edit_profile_dialog.dart'; import '../widgets/stats/profile_history_dialog.dart'; import '../widgets/stats/stats_section_widget.dart'; @@ -111,6 +112,8 @@ class ProfilePage extends StatelessWidget { onPressed: () => _openHistoryDialog(context), child: const Text(AppStrings.profileStatsHistoryButton), ), + const SizedBox(height: 36), + const CurrentPhaseSectionWidget(), ], ), ); diff --git a/lib/features/profile/presentation/widgets/current_phase_section_widget.dart b/lib/features/profile/presentation/widgets/current_phase_section_widget.dart new file mode 100644 index 00000000..773fbfc9 --- /dev/null +++ b/lib/features/profile/presentation/widgets/current_phase_section_widget.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../cubits/profile_statistics_cubit.dart'; +import '../cubits/profile_user_cubit.dart'; + +/// Read-only profile section with the current phase name and summary numbers. +class CurrentPhaseSectionWidget extends StatelessWidget { + /// Creates an instance of [CurrentPhaseSectionWidget]. + const CurrentPhaseSectionWidget({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => + previous.phaseSnapshot != current.phaseSnapshot || + previous.isLoading != current.isLoading, + builder: (context, userState) { + final phaseSnapshot = userState.phaseSnapshot; + if (phaseSnapshot == null) { + return _CurrentPhaseErrorState( + onRetryPressed: () => context.read().refresh(), + isLoading: userState.isLoading, + ); + } + + if (!phaseSnapshot.hasProgress || (phaseSnapshot.currentPhaseName?.isEmpty ?? true)) { + return _CurrentPhaseEmptyState( + currentPhaseName: phaseSnapshot.currentPhaseName, + ); + } + + return BlocBuilder( + buildWhen: (previous, current) => + previous.currentPhaseSummary != current.currentPhaseSummary || + previous.isLoadingCurrentPhaseSummary != current.isLoadingCurrentPhaseSummary || + previous.currentPhaseSummaryFailure != current.currentPhaseSummaryFailure, + builder: (context, statisticsState) { + final currentPhaseSummary = statisticsState.currentPhaseSummary; + if (currentPhaseSummary == null) { + if (statisticsState.isLoadingCurrentPhaseSummary) { + return _CurrentPhaseLoadingState( + currentPhaseName: phaseSnapshot.currentPhaseName!, + ); + } + + return _CurrentPhaseErrorState( + currentPhaseName: phaseSnapshot.currentPhaseName, + onRetryPressed: () => + context.read().reloadCurrentPhaseSummary(), + isLoading: false, + ); + } + + return _CurrentPhaseContent( + currentPhaseName: phaseSnapshot.currentPhaseName!, + averagePerWeek: currentPhaseSummary.averagePerWeek.round(), + weeklyGoal: '${currentPhaseSummary.weeklyGoal}', + ); + }, + ); + }, + ); + } +} + +final class _CurrentPhaseContent extends StatelessWidget { + final String currentPhaseName; + final int averagePerWeek; + final String weeklyGoal; + + const _CurrentPhaseContent({ + required this.currentPhaseName, + required this.averagePerWeek, + required this.weeklyGoal, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CurrentPhaseTitle(), + const SizedBox(height: 4), + _CurrentPhaseNameField(currentPhaseName: currentPhaseName), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _CurrentPhaseSummaryText(averagePerWeek: averagePerWeek), + ), + Padding( + padding: const EdgeInsets.only(right: 31), + child: _CurrentPhaseGoalBox(weeklyGoal: weeklyGoal), + ), + ], + ), + ], + ); + } +} + +final class _CurrentPhaseLoadingState extends StatelessWidget { + final String currentPhaseName; + + const _CurrentPhaseLoadingState({ + required this.currentPhaseName, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CurrentPhaseTitle(), + const SizedBox(height: 4), + _CurrentPhaseNameField(currentPhaseName: currentPhaseName), + const SizedBox(height: 20), + const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ), + ], + ); + } +} + +final class _CurrentPhaseErrorState extends StatelessWidget { + final String? currentPhaseName; + final VoidCallback onRetryPressed; + final bool isLoading; + + const _CurrentPhaseErrorState({ + required this.onRetryPressed, + required this.isLoading, + this.currentPhaseName, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CurrentPhaseTitle(), + const SizedBox(height: 4), + _CurrentPhaseNameField( + currentPhaseName: currentPhaseName ?? AppStrings.profileCurrentPhaseEmpty, + ), + const SizedBox(height: 20), + Text( + AppStrings.profileCurrentPhaseLoadFailed, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + state: isLoading ? ButtonState.loading : ButtonState.enabled, + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ); + } +} + +final class _CurrentPhaseEmptyState extends StatelessWidget { + final String? currentPhaseName; + + const _CurrentPhaseEmptyState({ + this.currentPhaseName, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CurrentPhaseTitle(), + const SizedBox(height: 4), + _CurrentPhaseNameField( + currentPhaseName: currentPhaseName ?? AppStrings.profileCurrentPhaseEmpty, + ), + const SizedBox(height: 20), + Text( + AppStrings.profileCurrentPhaseEmpty, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + ], + ); + } +} + +final class _CurrentPhaseTitle extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Text( + AppStrings.profileCurrentPhaseTitle, + style: textTheme.bodyMedium.copyWith( + color: colorTheme.onSurface, + ), + ); + } +} + +final class _CurrentPhaseNameField extends StatelessWidget { + final String currentPhaseName; + + const _CurrentPhaseNameField({ + required this.currentPhaseName, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + currentPhaseName, + style: textTheme.bodyMedium.copyWith( + color: colorTheme.darkHint, + ), + ), + ), + ); + } +} + +final class _CurrentPhaseSummaryText extends StatelessWidget { + final int averagePerWeek; + + const _CurrentPhaseSummaryText({ + required this.averagePerWeek, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Text( + '${AppStrings.profileCurrentPhaseTrainingsPerWeek(averagePerWeek)}\n' + '${AppStrings.profileCurrentPhaseRecommendation}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.start, + style: textTheme.body.copyWith( + color: colorTheme.onSurface, + ), + ); + } +} + +final class _CurrentPhaseGoalBox extends StatelessWidget { + final String weeklyGoal; + + const _CurrentPhaseGoalBox({ + required this.weeklyGoal, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return SizedBox( + width: 41, + height: 41, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: colorTheme.outline), + ), + child: Center( + child: Text( + weeklyGoal, + style: textTheme.bodyMedium.copyWith( + color: colorTheme.darkHint, + ), + ), + ), + ), + ); + } +} diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 93d15bd4..531c0a85 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -10,6 +10,7 @@ import 'package:moveup_flutter/features/profile/data/dto/change_password_request import 'package:moveup_flutter/features/profile/data/dto/update_profile_request_dto.dart'; import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart'; import 'package:moveup_flutter/features/profile/data/repositories/profile_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; @@ -363,6 +364,120 @@ void main() { verify(apiClient.getProfile()).called(1); verifyNoMoreInteractions(apiClient); }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getStatsHistorySnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getPhaseSnapshot', () { + test('returns snapshot from cache after getUser succeeds', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + phase: createProfilePhaseDto(), + ), + ); + + // Act + final getUserResult = await repository.getUser(); + final phaseResult = await repository.getPhaseSnapshot(); + + // Assert + expect(getUserResult.isSuccess, isTrue); + expect(phaseResult.isSuccess, isTrue); + expect(phaseResult.success, createProfilePhaseSnapshot()); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns phase snapshot from /profile when cache is empty', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + phase: createProfilePhaseDto( + currentPhase: createProfileCurrentPhaseDto( + id: 12, + name: 'B2', + ), + ), + ), + ); + + // Act + final result = await repository.getPhaseSnapshot(); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + const ProfilePhaseSnapshot( + hasProgress: true, + currentPhaseName: 'B2', + ), + ); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api returns server error', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getPhaseSnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getPhaseSnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); }); group('changePassword', () { diff --git a/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart b/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart index 8ae9dced..bf3fb835 100644 --- a/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_statistics_repository_impl_test.dart @@ -27,6 +27,64 @@ void main() { }); group('ProfileStatisticsRepositoryImpl', () { + group('getCurrentPhaseSummary()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when( + apiClient.getOverview(), + ).thenAnswer((_) async => createProfileStatisticsOverviewResponseDto()); + + // Act + final result = await repository.getCurrentPhaseSummary(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, testProfileCurrentPhaseSummary); + + verify(apiClient.getOverview()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileStatisticsDioBadResponseException( + path: '/api/profile/statistics', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getOverview()).thenThrow(exception); + + // Act + final result = await repository.getCurrentPhaseSummary(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getOverview()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getOverview()).thenThrow(exception); + + // Act + final result = await repository.getCurrentPhaseSummary(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getOverview()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + group('getVolume()', () { test('returns success(data) when api succeeds', () async { // Arrange diff --git a/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart index cf6eb1e5..bc7cf16e 100644 --- a/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart @@ -6,6 +6,7 @@ import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dar import 'package:moveup_flutter/core/result/result.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_history_tab.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_statistics_mode.dart'; @@ -36,6 +37,9 @@ void main() { provideDummy>( const Success(testProfileStatisticsFrequencyData), ); + provideDummy>( + const Success(testProfileCurrentPhaseSummary), + ); provideDummy, ProfileFailure>>( const Success(testProfileStatisticsExercises), ); @@ -51,6 +55,9 @@ void main() { when(repository.getVolume()).thenAnswer( (_) async => const Success(testProfileStatisticsVolumeData), ); + when(repository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ); when(repository.getExercises()).thenAnswer( (_) async => const Success(testProfileStatisticsExercises), ); @@ -60,15 +67,18 @@ void main() { expect: () => const [ ProfileStatisticsState( isLoading: true, + isLoadingCurrentPhaseSummary: true, ), ProfileStatisticsState( selectedExerciseId: 17, + currentPhaseSummary: testProfileCurrentPhaseSummary, volumeData: testProfileStatisticsVolumeData, exerciseOptions: testProfileStatisticsExercises, ), ], verify: (_) { verify(repository.getVolume()).called(1); + verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).called(1); }, ); @@ -77,6 +87,9 @@ void main() { 'stores failure when initial volume load fails', setUp: () { when(repository.getVolume()).thenAnswer((_) async => const Failure(failure)); + when(repository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ); when(repository.getExercises()).thenAnswer( (_) async => const Success(testProfileStatisticsExercises), ); @@ -86,17 +99,93 @@ void main() { expect: () => const [ ProfileStatisticsState( isLoading: true, + isLoadingCurrentPhaseSummary: true, ), ProfileStatisticsState( + currentPhaseSummary: testProfileCurrentPhaseSummary, failure: failure, ), ], verify: (_) { verify(repository.getVolume()).called(1); + verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).called(1); }, ); + blocTest( + 'reloads current phase summary without affecting statistics mode payload', + setUp: () => when( + repository.getCurrentPhaseSummary(), + ).thenAnswer((_) async => const Success(testProfileCurrentPhaseSummary)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + act: (cubit) => cubit.reloadCurrentPhaseSummary(), + expect: () => const [ + ProfileStatisticsState( + isLoadingCurrentPhaseSummary: true, + selectedExerciseId: 17, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ProfileStatisticsState( + selectedExerciseId: 17, + currentPhaseSummary: testProfileCurrentPhaseSummary, + volumeData: testProfileStatisticsVolumeData, + exerciseOptions: testProfileStatisticsExercises, + ), + ], + verify: (_) => verify(repository.getCurrentPhaseSummary()).called(1), + ); + + blocTest( + 'stores failure when reloading current phase summary fails', + setUp: () => when( + repository.getCurrentPhaseSummary(), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + seed: () => const ProfileStatisticsState( + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + act: (cubit) => cubit.reloadCurrentPhaseSummary(), + expect: () => const [ + ProfileStatisticsState( + isLoadingCurrentPhaseSummary: true, + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + ProfileStatisticsState( + currentPhaseSummary: testProfileCurrentPhaseSummary, + currentPhaseSummaryFailure: failure, + ), + ], + verify: (_) => verify(repository.getCurrentPhaseSummary()).called(1), + ); + + blocTest( + 'reloadCurrentPhaseSummary ignores repeated calls while request is in progress', + setUp: () => when( + repository.getCurrentPhaseSummary(), + ).thenAnswer((_) async => const Success(testProfileCurrentPhaseSummary)), + build: () => cubit, + act: (cubit) { + cubit.reloadCurrentPhaseSummary(); + cubit.reloadCurrentPhaseSummary(); + }, + expect: () => const [ + ProfileStatisticsState( + isLoadingCurrentPhaseSummary: true, + ), + ProfileStatisticsState( + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + ], + verify: (_) => verify(repository.getCurrentPhaseSummary()).called(1), + ); + blocTest( 'loads frequency mode on demand', setUp: () => when( diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart index 1f92b670..b75788d9 100644 --- a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -5,6 +5,7 @@ import 'package:mockito/mockito.dart'; import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; import 'package:moveup_flutter/core/result/result.dart'; import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; import 'package:moveup_flutter/features/profile/presentation/cubits/profile_user_cubit.dart'; @@ -37,6 +38,9 @@ void main() { provideDummy>( Success(createProfileStatsHistorySnapshot()), ); + provideDummy>( + Success(createProfilePhaseSnapshot()), + ); }); group('ProfileUserCubit', () { @@ -47,6 +51,9 @@ void main() { when(repository.getStatsHistorySnapshot()).thenAnswer( (_) async => Success(createProfileStatsHistorySnapshot()), ); + when(repository.getPhaseSnapshot()).thenAnswer( + (_) async => Success(createProfilePhaseSnapshot()), + ); }, build: () => cubit, act: (cubit) => cubit.refresh(), @@ -76,11 +83,16 @@ void main() { completedAt: testProfileTestCompletedAt, ), ), + phaseSnapshot: ProfilePhaseSnapshot( + hasProgress: testProfileHasProgress, + currentPhaseName: testProfilePhaseName, + ), ), ], verify: (_) { verify(repository.getUser()).called(1); verify(repository.getStatsHistorySnapshot()).called(1); + verify(repository.getPhaseSnapshot()).called(1); }, ); @@ -91,6 +103,9 @@ void main() { when(repository.getStatsHistorySnapshot()).thenAnswer( (_) async => Success(createProfileStatsHistorySnapshot()), ); + when(repository.getPhaseSnapshot()).thenAnswer( + (_) async => Success(createProfilePhaseSnapshot()), + ); }, build: () => cubit, act: (cubit) { @@ -123,11 +138,16 @@ void main() { completedAt: testProfileTestCompletedAt, ), ), + phaseSnapshot: ProfilePhaseSnapshot( + hasProgress: testProfileHasProgress, + currentPhaseName: testProfilePhaseName, + ), ), ], verify: (_) { verify(repository.getUser()).called(1); verify(repository.getStatsHistorySnapshot()).called(1); + verify(repository.getPhaseSnapshot()).called(1); }, ); diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart index e5266fe4..698c2b44 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -6,6 +6,7 @@ import 'package:moveup_flutter/features/profile/data/dto/profile_user_data_dto.d import 'package:moveup_flutter/features/profile/data/dto/profile_user_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_user_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_workout_history_item_dto.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; const testProfileUserId = 1; @@ -27,6 +28,9 @@ const testProfileTestAttemptId = 3; const testProfileTestId = 2; const testProfileTestTitle = 'Базовый тест'; const testProfileTestCompletedAt = '2026-03-14 15:20:00'; +const testProfilePhaseId = 7; +const testProfilePhaseName = 'A1'; +const testProfileHasProgress = true; /// Test fixture for a shared authenticated [User]. User createProfileUser({ @@ -64,15 +68,35 @@ ProfileUserResponseDto createProfileUserResponseDto({ ProfileSubscriptionsDto? subscriptions, ProfileWorkoutsDto? workouts, ProfileTestsDto? tests, + ProfilePhaseDto? phase, }) => ProfileUserResponseDto( data: ProfileUserDataDto( user: user ?? createProfileUserDto(), subscriptions: subscriptions, workouts: workouts, tests: tests, + phase: phase, ), ); +/// Test fixture for [ProfilePhaseDto]. +ProfilePhaseDto createProfilePhaseDto({ + bool hasProgress = testProfileHasProgress, + ProfileCurrentPhaseDto? currentPhase, +}) => ProfilePhaseDto( + hasProgress: hasProgress, + currentPhase: currentPhase ?? createProfileCurrentPhaseDto(), +); + +/// Test fixture for [ProfileCurrentPhaseDto]. +ProfileCurrentPhaseDto createProfileCurrentPhaseDto({ + int id = testProfilePhaseId, + String name = testProfilePhaseName, +}) => ProfileCurrentPhaseDto( + id: id, + name: name, +); + /// Test fixture for [ProfileSubscriptionsDto]. ProfileSubscriptionsDto createProfileSubscriptionsDto({ ActiveProfileSubscriptionDto? active, @@ -178,6 +202,15 @@ ProfileStatsHistorySnapshot createProfileStatsHistorySnapshot({ ), ); +/// Test fixture for [ProfilePhaseSnapshot]. +ProfilePhaseSnapshot createProfilePhaseSnapshot({ + bool hasProgress = testProfileHasProgress, + String? currentPhaseName = testProfilePhaseName, +}) => ProfilePhaseSnapshot( + hasProgress: hasProgress, + currentPhaseName: currentPhaseName, +); + /// Test fixture for Dio bad response exception. DioException createProfileDioBadResponseException({ required String path, diff --git a/test/features/profile/support/profile_statistics_dto_fixtures.dart b/test/features/profile/support/profile_statistics_dto_fixtures.dart index bb7f612d..04eca33f 100644 --- a/test/features/profile/support/profile_statistics_dto_fixtures.dart +++ b/test/features/profile/support/profile_statistics_dto_fixtures.dart @@ -1,11 +1,13 @@ import 'package:dio/dio.dart'; import 'package:moveup_flutter/features/profile/data/dto/stats/frequency_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/stats/profile_exercises_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/stats/profile_statistics_overview_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/stats/profile_workouts_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/stats/trend_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/stats/volume_response_dto.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_period.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/frequency_statistics_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_exercise_option.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_workout_option.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/trend_statistics_data.dart'; @@ -24,6 +26,8 @@ const testTrendWorkoutId = 231; const testTrendWorkoutTitle = 'Силовая: Грудь + трицепс'; const testTrendWorkoutCompletedFormatted = '18.03.2026 08:32'; const testFrequencyLabel = 'Текущий месяц'; +const testProfileCurrentPhaseAveragePerWeek = 2.3; +const testProfileCurrentPhaseWeeklyGoal = 4; const testProfileStatisticsVolumeData = VolumeStatisticsData( hasData: true, @@ -98,6 +102,11 @@ const testProfileStatisticsFrequencyData = FrequencyStatisticsData( ], ); +const testProfileCurrentPhaseSummary = ProfileCurrentPhaseSummary( + averagePerWeek: testProfileCurrentPhaseAveragePerWeek, + weeklyGoal: testProfileCurrentPhaseWeeklyGoal, +); + const testProfileStatisticsYearFrequencyData = FrequencyStatisticsData( hasData: true, period: FrequencyPeriod.year, @@ -269,6 +278,36 @@ FrequencyResponseDto createFrequencyResponseDto({ data: data ?? createFrequencyStatisticsDto(), ); +/// Test fixture for [ProfileStatisticsOverviewResponseDto]. +ProfileStatisticsOverviewResponseDto createProfileStatisticsOverviewResponseDto({ + ProfileStatisticsOverviewDataDto? data, +}) => ProfileStatisticsOverviewResponseDto( + data: data ?? createProfileStatisticsOverviewDataDto(), +); + +/// Test fixture for [ProfileStatisticsOverviewDataDto]. +ProfileStatisticsOverviewDataDto createProfileStatisticsOverviewDataDto({ + ProfileStatisticsOverviewFrequencyDto? frequency, +}) => ProfileStatisticsOverviewDataDto( + frequency: frequency ?? createProfileStatisticsOverviewFrequencyDto(), +); + +/// Test fixture for [ProfileStatisticsOverviewFrequencyDto]. +ProfileStatisticsOverviewFrequencyDto createProfileStatisticsOverviewFrequencyDto({ + ProfileStatisticsOverviewFrequencySummaryDto? summary, +}) => ProfileStatisticsOverviewFrequencyDto( + summary: summary ?? createProfileStatisticsOverviewFrequencySummaryDto(), +); + +/// Test fixture for [ProfileStatisticsOverviewFrequencySummaryDto]. +ProfileStatisticsOverviewFrequencySummaryDto createProfileStatisticsOverviewFrequencySummaryDto({ + double averagePerWeek = testProfileCurrentPhaseAveragePerWeek, + int weeklyGoal = testProfileCurrentPhaseWeeklyGoal, +}) => ProfileStatisticsOverviewFrequencySummaryDto( + averagePerWeek: averagePerWeek, + weeklyGoal: weeklyGoal, +); + /// Test fixture for [FrequencyStatisticsDto]. FrequencyStatisticsDto createFrequencyStatisticsDto({ bool hasData = true, From 87063cf5d7c32726450f5572e44d418234f96c1a Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:46:02 +0700 Subject: [PATCH 05/13] feat(profile): add editable personal parameters section with workouts reload sync (#55) * feat(profile): extend profile bootstrap with phase snapshot * test(profile): add phase bootstrap repository coverage * feat(profile): add current phase summary to profile statistics flow * test(profile): add current phase summary coverage * feat(profile-ui): add current phase section UI * docs: update CHANGELOG.md * fix(profile-ui): polish ui and decline current phase rounded training count in russian * feat(params): add profile params api contract * feat(params-domain): add profile parameters repository contract * feat(params-data): implement profile parameters repository * test(params-repo): add unit tests for repository (+ fixtures) * feat(params): add ProfileParametersCubit and state * test(params): add unit tests for ProfileParametersCubit * feat(params-ui): add profile parameters section UI * docs: update CHANGELOG.md * chore(profile): align phase dto nullability and cache cleanup * refactor(params-ui): polish ProfileParametersSectionWidget layout * test(params): add dio exception test for saving one param * refactor(profile): reload workouts only for plan-affecting parameter changes * refactor(profile): add typed request dto for parameters api * fix(profile): guard parameters flow against load-submit races * fix(profile): cache nullable parameters snapshot from profile bootstrap * fix(profile): handle equipment options safely in parameters form * docs: refine profile changelog wording --- CHANGELOG.md | 1 + assets/icons/arrow_down.svg | 3 + lib/core/constants/app_assets.dart | 1 + lib/core/constants/app_strings.dart | 10 + lib/core/di/di.dart | 17 + lib/core/network/api_paths.dart | 6 + ...ofile_current_parameters_response_dto.dart | 101 +++ ...le_parameters_references_response_dto.dart | 61 ++ .../profile_parameters_request_dto.dart | 83 ++ .../data/dto/profile_user_data_dto.dart | 44 + .../mappers/profile_parameters_mapper.dart | 61 ++ .../remote/profile_parameters_api_client.dart | 43 + .../profile_parameters_repository_impl.dart | 118 +++ .../repositories/profile_repository_impl.dart | 37 + .../profile_parameters_data.dart | 64 ++ .../profile_parameters_gender.dart | 21 + .../profile_parameters_option.dart | 19 + .../profile_parameters_references.dart | 25 + .../profile_parameters_snapshot.dart | 41 + .../profile_parameters_submit_payload.dart | 54 ++ .../profile_parameters_repository.dart | 21 + .../repositories/profile_repository.dart | 4 + .../cubits/profile_parameters_cubit.dart | 226 +++++ .../cubits/profile_parameters_state.dart | 20 + .../cubits/profile_user_cubit.dart | 8 + .../cubits/profile_user_state.dart | 1 + .../presentation/pages/profile_page.dart | 14 +- .../pages/profile_page_builder.dart | 7 + .../profile_parameters_section_widget.dart | 799 ++++++++++++++++++ .../pages/workouts_overview_page_builder.dart | 5 +- lib/uikit/buttons/option_button.dart | 12 +- ...ofile_parameters_repository_impl_test.dart | 405 +++++++++ .../profile_repository_impl_test.dart | 150 ++++ .../cubits/profile_parameters_cubit_test.dart | 511 +++++++++++ .../cubits/profile_user_cubit_test.dart | 31 + .../profile/support/profile_dto_fixtures.dart | 49 ++ .../profile_parameters_dto_fixtures.dart | 202 +++++ 37 files changed, 3266 insertions(+), 9 deletions(-) create mode 100644 assets/icons/arrow_down.svg create mode 100644 lib/features/profile/data/dto/params/profile_current_parameters_response_dto.dart create mode 100644 lib/features/profile/data/dto/params/profile_parameters_references_response_dto.dart create mode 100644 lib/features/profile/data/dto/params/profile_parameters_request_dto.dart create mode 100644 lib/features/profile/data/mappers/profile_parameters_mapper.dart create mode 100644 lib/features/profile/data/remote/profile_parameters_api_client.dart create mode 100644 lib/features/profile/data/repositories/profile_parameters_repository_impl.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_option.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart create mode 100644 lib/features/profile/domain/entities/profile_parameters/profile_parameters_submit_payload.dart create mode 100644 lib/features/profile/domain/repositories/profile_parameters_repository.dart create mode 100644 lib/features/profile/presentation/cubits/profile_parameters_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_parameters_state.dart create mode 100644 lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart create mode 100644 test/features/profile/data/repositories/profile_parameters_repository_impl_test.dart create mode 100644 test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart create mode 100644 test/features/profile/support/profile_parameters_dto_fixtures.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0f69bf..8e288cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout. - Profile statistics section for the authenticated `/profile` tab, including dedicated statistics API client/repository, focused `/profile` history snapshot mapping, statistics Cubit/state flow, chart widgets, selectors, history dialog, and widget coverage for the integrated UI. - Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice. +- Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan. ### Changed diff --git a/assets/icons/arrow_down.svg b/assets/icons/arrow_down.svg new file mode 100644 index 00000000..e4dc4321 --- /dev/null +++ b/assets/icons/arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 3e33b4e1..96e3600c 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -18,6 +18,7 @@ abstract final class AppAssets { static const iconBadFace = 'bad_face'; static const iconNormalFace = 'normal_face'; static const iconGoodFace = 'good_face'; + static const iconArrowDown = 'arrow_down'; // Images. static const imageFigure = 'figure'; diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index afbc0db8..2b45018b 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -266,6 +266,16 @@ abstract final class AppStrings { static const profileCurrentPhaseRecommendation = 'Вам рекомендуется тренироваться в неделю'; static const profileCurrentPhaseEmpty = 'У вас пока нет активной фазы'; static const profileCurrentPhaseLoadFailed = 'Не удалось загрузить текущую фазу'; + static const profileParametersGoalLabel = 'Цель тренировок'; + static const profileParametersLevelLabel = 'Уровень подготовки'; + static const profileParametersWeeklyGoalLabel = 'Количество тренировок в неделю'; + static const profileParametersSubmitButton = 'Подтвердить'; + static const profileParametersLoadFailed = 'Не удалось загрузить параметры профиля'; + static const profileParametersEquipmentUnavailable = 'Нет доступных вариантов'; + static const profileParametersWeeklyGoalRequired = 'Введите количество тренировок в неделю'; + static const profileParametersWeeklyGoalInvalid = + 'Введите корректное количество тренировок в неделю'; + static const profileParametersWeeklyGoalRange = 'Допустимо от 1 до 7 тренировок в неделю'; static const profileStatsTitle = 'Статистика тренировок пользователя'; static const profileStatsHistoryButton = 'История'; static const profileStatsVolumeMode = 'Объём'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index cc01afe8..fe5eee90 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -18,9 +18,12 @@ import '../../features/fitness_start/data/repositories/fitness_start_repository_ import '../../features/fitness_start/domain/repositories/fitness_start_repository.dart'; import '../../features/offline/presentation/cubit/network_cubit.dart'; import '../../features/profile/data/remote/profile_api_client.dart'; +import '../../features/profile/data/remote/profile_parameters_api_client.dart'; import '../../features/profile/data/remote/profile_statistics_api_client.dart'; +import '../../features/profile/data/repositories/profile_parameters_repository_impl.dart'; import '../../features/profile/data/repositories/profile_repository_impl.dart'; import '../../features/profile/data/repositories/profile_statistics_repository_impl.dart'; +import '../../features/profile/domain/repositories/profile_parameters_repository.dart'; import '../../features/profile/domain/repositories/profile_repository.dart'; import '../../features/profile/domain/repositories/profile_statistics_repository.dart'; import '../../features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart'; @@ -36,6 +39,7 @@ import '../../features/workouts/execution/data/repositories/workout_execution_re import '../../features/workouts/execution/domain/repositories/workout_execution_repository.dart'; import '../../features/workouts/overview/data/repositories/workouts_overview_repository_impl.dart'; import '../../features/workouts/overview/domain/repositories/workouts_overview_repository.dart'; +import '../../features/workouts/overview/presentation/cubits/workouts_overview_cubit.dart'; import '../network/api_paths.dart'; import '../network/dio_setup.dart'; import '../services/fitness_start_progress_storage/fitness_start_progress_storage.dart'; @@ -119,6 +123,9 @@ Future setupDI() async { ), ); di.registerLazySingleton(() => ProfileApiClient(di())); + di.registerLazySingleton( + () => ProfileParametersApiClient(di()), + ); di.registerLazySingleton( () => ProfileStatisticsApiClient(di()), ); @@ -134,6 +141,12 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton( + () => ProfileParametersRepositoryImpl( + di(), + di(), + ), + ); // Fitness Start di.registerLazySingleton(() => FitnessStartApiClient(di())); @@ -183,6 +196,10 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton( + () => WorkoutsOverviewCubit(di()), + dispose: (cubit) => cubit.close(), + ); di.registerLazySingleton( () => WorkoutDetailsRepositoryImpl( di(), diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index eea9fdc2..d801bcc3 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -71,6 +71,9 @@ abstract class ApiPaths { /// The endpoint for all user-parameters references. static const String userParameterReferences = '${apiPrefix}user-parameters/references'; + /// The endpoint for the current authenticated user parameters. + static const String userParameterMe = '${apiPrefix}user-parameters/me'; + /// The endpoint for saving user training goal. static const String userParameterGoal = '${apiPrefix}user-parameters/goal'; @@ -80,6 +83,9 @@ abstract class ApiPaths { /// The endpoint for saving user fitness level. static const String userParameterLevel = '${apiPrefix}user-parameters/level'; + /// The endpoint for updating the current weekly training goal. + static const String userWeeklyGoal = '${apiPrefix}user/weekly-goal'; + /// The endpoint for all active testings. static const String testings = '${apiPrefix}testings'; diff --git a/lib/features/profile/data/dto/params/profile_current_parameters_response_dto.dart b/lib/features/profile/data/dto/params/profile_current_parameters_response_dto.dart new file mode 100644 index 00000000..bd41c249 --- /dev/null +++ b/lib/features/profile/data/dto/params/profile_current_parameters_response_dto.dart @@ -0,0 +1,101 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_current_parameters_response_dto.g.dart'; + +/// DTO for the authenticated user-parameters response. +@JsonSerializable(createToJson: false) +class ProfileCurrentParametersResponseDto { + /// Nested parameters payload. + final ProfileCurrentParametersDto data; + + /// Creates an instance of [ProfileCurrentParametersResponseDto]. + ProfileCurrentParametersResponseDto({required this.data}); + + /// Creates a [ProfileCurrentParametersResponseDto] from JSON. + factory ProfileCurrentParametersResponseDto.fromJson(Map json) => + _$ProfileCurrentParametersResponseDtoFromJson(json); +} + +/// Canonical authenticated parameters payload. +@JsonSerializable(createToJson: false) +class ProfileCurrentParametersDto { + /// Persistent parameters identifier. + final int id; + + /// Authenticated user identifier. + @JsonKey(name: 'user_id') + final int userId; + + /// Selected equipment identifier. + @JsonKey(name: 'equipment_id') + final int equipmentId; + + /// Selected level identifier. + @JsonKey(name: 'level_id') + final int levelId; + + /// Selected goal identifier. + @JsonKey(name: 'goal_id') + final int goalId; + + /// User height in centimeters. + final int height; + + /// User weight in kilograms. + final double weight; + + /// User age in years. + final int age; + + /// Selected gender value. + final String gender; + + /// Selected goal details. + final ProfileCurrentParameterNamedItemDto goal; + + /// Selected level details. + final ProfileCurrentParameterNamedItemDto level; + + /// Selected equipment details. + final ProfileCurrentParameterNamedItemDto equipment; + + /// Creates an instance of [ProfileCurrentParametersDto]. + ProfileCurrentParametersDto({ + required this.id, + required this.userId, + required this.equipmentId, + required this.levelId, + required this.goalId, + required this.height, + required this.weight, + required this.age, + required this.gender, + required this.goal, + required this.level, + required this.equipment, + }); + + /// Creates a [ProfileCurrentParametersDto] from JSON. + factory ProfileCurrentParametersDto.fromJson(Map json) => + _$ProfileCurrentParametersDtoFromJson(json); +} + +/// Shared named nested item from the authenticated parameters payload. +@JsonSerializable(createToJson: false) +class ProfileCurrentParameterNamedItemDto { + /// Item identifier. + final int id; + + /// Display name. + final String name; + + /// Creates an instance of [ProfileCurrentParameterNamedItemDto]. + ProfileCurrentParameterNamedItemDto({ + required this.id, + required this.name, + }); + + /// Creates a [ProfileCurrentParameterNamedItemDto] from JSON. + factory ProfileCurrentParameterNamedItemDto.fromJson(Map json) => + _$ProfileCurrentParameterNamedItemDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/params/profile_parameters_references_response_dto.dart b/lib/features/profile/data/dto/params/profile_parameters_references_response_dto.dart new file mode 100644 index 00000000..637902c0 --- /dev/null +++ b/lib/features/profile/data/dto/params/profile_parameters_references_response_dto.dart @@ -0,0 +1,61 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_parameters_references_response_dto.g.dart'; + +/// DTO for profile parameters references response. +@JsonSerializable(createToJson: false) +class ProfileParametersReferencesResponseDto { + /// Nested references payload. + final ProfileParametersReferencesDto data; + + /// Creates an instance of [ProfileParametersReferencesResponseDto]. + ProfileParametersReferencesResponseDto({required this.data}); + + /// Creates a [ProfileParametersReferencesResponseDto] from JSON. + factory ProfileParametersReferencesResponseDto.fromJson(Map json) => + _$ProfileParametersReferencesResponseDtoFromJson(json); +} + +/// References payload required by the profile parameters section. +@JsonSerializable(createToJson: false) +class ProfileParametersReferencesDto { + /// Available goal options. + final List goals; + + /// Available preparation level options. + final List levels; + + /// Available equipment options. + final List equipment; + + /// Creates an instance of [ProfileParametersReferencesDto]. + ProfileParametersReferencesDto({ + required this.goals, + required this.levels, + required this.equipment, + }); + + /// Creates a [ProfileParametersReferencesDto] from JSON. + factory ProfileParametersReferencesDto.fromJson(Map json) => + _$ProfileParametersReferencesDtoFromJson(json); +} + +/// Shared reference option item. +@JsonSerializable(createToJson: false) +class ProfileParametersReferenceOptionDto { + /// Option identifier. + final int id; + + /// Display name. + final String name; + + /// Creates an instance of [ProfileParametersReferenceOptionDto]. + ProfileParametersReferenceOptionDto({ + required this.id, + required this.name, + }); + + /// Creates a [ProfileParametersReferenceOptionDto] from JSON. + factory ProfileParametersReferenceOptionDto.fromJson(Map json) => + _$ProfileParametersReferenceOptionDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/params/profile_parameters_request_dto.dart b/lib/features/profile/data/dto/params/profile_parameters_request_dto.dart new file mode 100644 index 00000000..a33b6d3f --- /dev/null +++ b/lib/features/profile/data/dto/params/profile_parameters_request_dto.dart @@ -0,0 +1,83 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'profile_parameters_request_dto.g.dart'; + +/// DTO for saving the selected training goal. +@JsonSerializable(createFactory: false) +class SaveProfileGoalRequestDto { + /// Selected goal identifier. + @JsonKey(name: 'goal_id') + final int goalId; + + /// Creates an instance of [SaveProfileGoalRequestDto]. + SaveProfileGoalRequestDto({ + required this.goalId, + }); + + /// Converts [SaveProfileGoalRequestDto] to JSON. + Map toJson() => _$SaveProfileGoalRequestDtoToJson(this); +} + +/// DTO for saving anthropometry values. +@JsonSerializable(createFactory: false) +class SaveProfileAnthropometryRequestDto { + /// Selected gender raw value. + final String gender; + + /// User age. + final int age; + + /// User weight. + final double weight; + + /// User height. + final int height; + + /// Selected equipment identifier. + @JsonKey(name: 'equipment_id') + final int equipmentId; + + /// Creates an instance of [SaveProfileAnthropometryRequestDto]. + SaveProfileAnthropometryRequestDto({ + required this.gender, + required this.age, + required this.weight, + required this.height, + required this.equipmentId, + }); + + /// Converts [SaveProfileAnthropometryRequestDto] to JSON. + Map toJson() => _$SaveProfileAnthropometryRequestDtoToJson(this); +} + +/// DTO for saving the selected preparation level. +@JsonSerializable(createFactory: false) +class SaveProfileLevelRequestDto { + /// Selected level identifier. + @JsonKey(name: 'level_id') + final int levelId; + + /// Creates an instance of [SaveProfileLevelRequestDto]. + SaveProfileLevelRequestDto({ + required this.levelId, + }); + + /// Converts [SaveProfileLevelRequestDto] to JSON. + Map toJson() => _$SaveProfileLevelRequestDtoToJson(this); +} + +/// DTO for updating weekly training goal. +@JsonSerializable(createFactory: false) +class UpdateProfileWeeklyGoalRequestDto { + /// Selected weekly goal. + @JsonKey(name: 'weekly_goal') + final int weeklyGoal; + + /// Creates an instance of [UpdateProfileWeeklyGoalRequestDto]. + UpdateProfileWeeklyGoalRequestDto({ + required this.weeklyGoal, + }); + + /// Converts [UpdateProfileWeeklyGoalRequestDto] to JSON. + Map toJson() => _$UpdateProfileWeeklyGoalRequestDtoToJson(this); +} diff --git a/lib/features/profile/data/dto/profile_user_data_dto.dart b/lib/features/profile/data/dto/profile_user_data_dto.dart index 0b2f87f4..78d5a304 100644 --- a/lib/features/profile/data/dto/profile_user_data_dto.dart +++ b/lib/features/profile/data/dto/profile_user_data_dto.dart @@ -25,6 +25,9 @@ class ProfileUserDataDto { /// Phase snapshot for the current phase section. final ProfilePhaseDto? phase; + /// Parameters snapshot for the editable parameters section. + final ProfileParametersInProfileDto? parameters; + /// Creates an instance of [ProfileUserDataDto]. ProfileUserDataDto({ required this.user, @@ -32,6 +35,7 @@ class ProfileUserDataDto { this.workouts, this.tests, this.phase, + this.parameters, }); /// Creates a [ProfileUserDataDto] from JSON. @@ -39,6 +43,46 @@ class ProfileUserDataDto { _$ProfileUserDataDtoFromJson(json); } +/// DTO with focused parameters payload from `/profile`. +@JsonSerializable(createToJson: false) +class ProfileParametersInProfileDto { + /// Goal display name. + final String goal; + + /// Selected gender value. + final String gender; + + /// User age in years. + final int age; + + /// User weight in kilograms. + final num weight; + + /// User height in centimeters. + final int height; + + /// Equipment display name. + final String equipment; + + /// Preparation level display name. + final String level; + + /// Creates an instance of [ProfileParametersInProfileDto]. + ProfileParametersInProfileDto({ + required this.goal, + required this.gender, + required this.age, + required this.weight, + required this.height, + required this.equipment, + required this.level, + }); + + /// Creates a [ProfileParametersInProfileDto] from JSON. + factory ProfileParametersInProfileDto.fromJson(Map json) => + _$ProfileParametersInProfileDtoFromJson(json); +} + /// DTO with focused phase payload from `/profile`. @JsonSerializable(createToJson: false) class ProfilePhaseDto { diff --git a/lib/features/profile/data/mappers/profile_parameters_mapper.dart b/lib/features/profile/data/mappers/profile_parameters_mapper.dart new file mode 100644 index 00000000..a4e19148 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_parameters_mapper.dart @@ -0,0 +1,61 @@ +import '../../domain/entities/profile_parameters/profile_parameters_data.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_gender.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_option.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_references.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_snapshot.dart'; +import '../dto/params/profile_current_parameters_response_dto.dart'; +import '../dto/params/profile_parameters_references_response_dto.dart'; +import '../dto/profile_user_data_dto.dart'; + +/// Maps aggregate `/profile` DTO subset to parameters bootstrap snapshot. +extension ProfileParametersSnapshotMapper on ProfileUserDataDto { + /// Returns a focused parameters snapshot for the profile parameters form. + ProfileParametersSnapshot? toParametersSnapshot() { + final parameters = this.parameters; + if (parameters == null) return null; + + return ProfileParametersSnapshot( + goal: parameters.goal, + gender: ProfileParametersGender.fromRawValue(parameters.gender), + age: parameters.age, + weight: parameters.weight.toDouble(), + height: parameters.height, + equipment: parameters.equipment, + level: parameters.level, + ); + } +} + +/// Maps canonical `/user-parameters/me` DTOs to profile parameters entities. +extension ProfileCurrentParametersMapper on ProfileCurrentParametersDto { + /// Returns the canonical profile parameters entity. + ProfileParametersData toEntity() => ProfileParametersData( + goalId: goalId, + equipmentId: equipmentId, + levelId: levelId, + gender: ProfileParametersGender.fromRawValue(gender), + age: age, + weight: weight, + height: height, + goalName: goal.name, + equipmentName: equipment.name, + levelName: level.name, + ); +} + +/// Maps references DTO to the profile parameters references entity. +extension ProfileParametersReferencesMapper on ProfileParametersReferencesDto { + /// Returns the references entity required by the form. + ProfileParametersReferences toEntity() => ProfileParametersReferences( + goals: goals.map((item) => item.toEntity()).toList(growable: false), + levels: levels.map((item) => item.toEntity()).toList(growable: false), + equipment: equipment.map((item) => item.toEntity()).toList(growable: false), + ); +} + +extension on ProfileParametersReferenceOptionDto { + ProfileParametersOption toEntity() => ProfileParametersOption( + id: id, + name: name, + ); +} diff --git a/lib/features/profile/data/remote/profile_parameters_api_client.dart b/lib/features/profile/data/remote/profile_parameters_api_client.dart new file mode 100644 index 00000000..8475f618 --- /dev/null +++ b/lib/features/profile/data/remote/profile_parameters_api_client.dart @@ -0,0 +1,43 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/params/profile_current_parameters_response_dto.dart'; +import '../dto/params/profile_parameters_references_response_dto.dart'; +import '../dto/params/profile_parameters_request_dto.dart'; + +part 'profile_parameters_api_client.g.dart'; + +/// Retrofit API client for authenticated profile parameters requests. +@RestApi() +abstract class ProfileParametersApiClient { + /// Creates an instance of [ProfileParametersApiClient]. + factory ProfileParametersApiClient( + Dio dio, { + String? baseUrl, + }) = _ProfileParametersApiClient; + + /// Returns the canonical authenticated user parameters. + @GET(ApiPaths.userParameterMe) + Future getCurrentParameters(); + + /// Returns references required by the profile parameters form. + @GET(ApiPaths.userParameterReferences) + Future getReferences(); + + /// Saves the selected training goal. + @POST(ApiPaths.userParameterGoal) + Future saveGoal(@Body() SaveProfileGoalRequestDto request); + + /// Saves anthropometry values. + @POST(ApiPaths.userParameterAnthropometry) + Future saveAnthropometry(@Body() SaveProfileAnthropometryRequestDto request); + + /// Saves the selected preparation level. + @POST(ApiPaths.userParameterLevel) + Future saveLevel(@Body() SaveProfileLevelRequestDto request); + + /// Saves the recommended weekly training goal. + @POST(ApiPaths.userWeeklyGoal) + Future updateWeeklyGoal(@Body() UpdateProfileWeeklyGoalRequestDto request); +} diff --git a/lib/features/profile/data/repositories/profile_parameters_repository_impl.dart b/lib/features/profile/data/repositories/profile_parameters_repository_impl.dart new file mode 100644 index 00000000..42317700 --- /dev/null +++ b/lib/features/profile/data/repositories/profile_parameters_repository_impl.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/logger/app_logger.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_data.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_references.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_submit_payload.dart'; +import '../../domain/repositories/profile_parameters_repository.dart'; +import '../dto/params/profile_parameters_request_dto.dart'; +import '../mappers/profile_failure_mapper.dart'; +import '../mappers/profile_parameters_mapper.dart'; +import '../remote/profile_parameters_api_client.dart'; + +/// Implementation of [ProfileParametersRepository]. +final class ProfileParametersRepositoryImpl implements ProfileParametersRepository { + final AppLogger _logger; + final ProfileParametersApiClient _apiClient; + + /// Creates an instance of [ProfileParametersRepositoryImpl]. + ProfileParametersRepositoryImpl(this._logger, this._apiClient); + + @override + Future> getReferences() async { + try { + final response = await _apiClient.getReferences(); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetProfileParametersReferences failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> getCurrentParameters() async { + try { + final response = await _apiClient.getCurrentParameters(); + return Result.success(response.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetCurrentProfileParameters failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> saveParameters({ + required ProfileParametersData currentParameters, + required int currentWeeklyGoal, + required ProfileParametersSubmitPayload payload, + }) async { + final hasGoalChanges = payload.goalId != currentParameters.goalId; + final hasAnthropometryChanges = + payload.gender != currentParameters.gender || + payload.age != currentParameters.age || + payload.weight != currentParameters.weight || + payload.height != currentParameters.height || + payload.equipmentId != currentParameters.equipmentId; + final hasLevelChanges = payload.levelId != currentParameters.levelId; + final hasWeeklyGoalChanges = payload.weeklyGoal != currentWeeklyGoal; + + if (!hasGoalChanges && !hasAnthropometryChanges && !hasLevelChanges && !hasWeeklyGoalChanges) { + return Result.success(currentParameters); + } + + try { + // Backend exposes separate delta endpoints, so this save flow is intentionally + // sequential and may partially persist before a later request fails. + if (hasGoalChanges) { + await _apiClient.saveGoal( + SaveProfileGoalRequestDto(goalId: payload.goalId), + ); + } + if (hasAnthropometryChanges) { + await _apiClient.saveAnthropometry( + SaveProfileAnthropometryRequestDto( + gender: payload.gender.requestValue, + age: payload.age, + weight: payload.weight, + height: payload.height, + equipmentId: payload.equipmentId, + ), + ); + } + if (hasLevelChanges) { + await _apiClient.saveLevel( + SaveProfileLevelRequestDto(levelId: payload.levelId), + ); + } + if (hasWeeklyGoalChanges) { + await _apiClient.updateWeeklyGoal( + UpdateProfileWeeklyGoalRequestDto(weeklyGoal: payload.weeklyGoal), + ); + } + + final refreshedResponse = await _apiClient.getCurrentParameters(); + return Result.success(refreshedResponse.data.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('SaveProfileParameters failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } +} diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index 6fd15d4f..34339f85 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -8,12 +8,14 @@ import '../../../../core/result/result.dart'; import '../../../../core/utils/logger/app_logger.dart'; import '../../../auth/domain/entities/user.dart'; import '../../domain/entities/profile_phase_snapshot.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_snapshot.dart'; import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; import '../dto/change_password_request_dto.dart'; import '../dto/update_profile_request_dto.dart'; import '../mappers/profile_failure_mapper.dart'; import '../mappers/profile_phase_snapshot_mapper.dart'; +import '../mappers/profile_parameters_mapper.dart'; import '../mappers/profile_history_snapshot_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -24,6 +26,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { final ProfileApiClient _apiClient; ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; ProfilePhaseSnapshot? _cachedPhaseSnapshot; + ProfileParametersSnapshot? _cachedParametersSnapshot; + bool _hasCachedParametersSnapshot = false; /// Creates an instance of [ProfileRepositoryImpl]. ProfileRepositoryImpl(this._logger, this._apiClient); @@ -34,6 +38,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { final response = await _apiClient.getProfile(); _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); + _cachedParametersSnapshot = response.data.toParametersSnapshot(); + _hasCachedParametersSnapshot = true; return Result.success(response.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -58,6 +64,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { final snapshot = response.data.toStatsHistorySnapshot(); _cachedStatsHistorySnapshot = snapshot; _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); + _cachedParametersSnapshot = response.data.toParametersSnapshot(); + _hasCachedParametersSnapshot = true; return Result.success(snapshot); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -82,6 +90,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { final snapshot = response.data.toPhaseSnapshot(); _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); _cachedPhaseSnapshot = snapshot; + _cachedParametersSnapshot = response.data.toParametersSnapshot(); + _hasCachedParametersSnapshot = true; return Result.success(snapshot); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); @@ -94,6 +104,31 @@ final class ProfileRepositoryImpl implements ProfileRepository { } } + @override + Future> getParametersSnapshot() async { + if (_hasCachedParametersSnapshot) { + return Result.success(_cachedParametersSnapshot); + } + + try { + final response = await _apiClient.getProfile(); + final snapshot = response.data.toParametersSnapshot(); + _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); + _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); + _cachedParametersSnapshot = snapshot; + _hasCachedParametersSnapshot = true; + return Result.success(snapshot); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('GetParametersSnapshot failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + @override Future> updateUser({ required User currentUser, @@ -132,6 +167,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { final refreshedResponse = await _apiClient.getProfile(); _cachedStatsHistorySnapshot = refreshedResponse.data.toStatsHistorySnapshot(); _cachedPhaseSnapshot = refreshedResponse.data.toPhaseSnapshot(); + _cachedParametersSnapshot = refreshedResponse.data.toParametersSnapshot(); + _hasCachedParametersSnapshot = true; return Result.success(refreshedResponse.data.user.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart new file mode 100644 index 00000000..8ad6aa3b --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart @@ -0,0 +1,64 @@ +import 'package:equatable/equatable.dart'; + +import 'profile_parameters_gender.dart'; + +/// Canonical authenticated parameters payload for the profile form. +final class ProfileParametersData extends Equatable { + /// Selected goal identifier. + final int goalId; + + /// Selected equipment identifier. + final int equipmentId; + + /// Selected preparation level identifier. + final int levelId; + + /// Selected gender value. + final ProfileParametersGender gender; + + /// User age in years. + final int age; + + /// User weight in kilograms. + final double weight; + + /// User height in centimeters. + final int height; + + /// Goal display name. + final String goalName; + + /// Equipment display name. + final String equipmentName; + + /// Preparation level display name. + final String levelName; + + /// Creates an instance of [ProfileParametersData]. + const ProfileParametersData({ + required this.goalId, + required this.equipmentId, + required this.levelId, + required this.gender, + required this.age, + required this.weight, + required this.height, + required this.goalName, + required this.equipmentName, + required this.levelName, + }); + + @override + List get props => [ + goalId, + equipmentId, + levelId, + gender, + age, + weight, + height, + goalName, + equipmentName, + levelName, + ]; +} diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart new file mode 100644 index 00000000..29655f92 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart @@ -0,0 +1,21 @@ +/// Supported gender values for the profile parameters section. +enum ProfileParametersGender { + /// Male gender. + male('male'), + + /// Female gender. + female('female'); + + /// Backend request value. + final String requestValue; + + const ProfileParametersGender(this.requestValue); + + /// Maps backend value to [ProfileParametersGender]. + static ProfileParametersGender fromRawValue(String rawValue) { + return ProfileParametersGender.values.firstWhere( + (item) => item.requestValue == rawValue, + orElse: () => ProfileParametersGender.male, + ); + } +} diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_option.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_option.dart new file mode 100644 index 00000000..8bb3c80c --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_option.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; + +/// Single-select option used by the profile parameters section. +final class ProfileParametersOption extends Equatable { + /// Option identifier. + final int id; + + /// Human-readable option name. + final String name; + + /// Creates an instance of [ProfileParametersOption]. + const ProfileParametersOption({ + required this.id, + required this.name, + }); + + @override + List get props => [id, name]; +} diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart new file mode 100644 index 00000000..72e1374e --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart @@ -0,0 +1,25 @@ +import 'package:equatable/equatable.dart'; + +import 'profile_parameters_option.dart'; + +/// Reference data used to render the profile parameters form. +final class ProfileParametersReferences extends Equatable { + /// Available goal options. + final List goals; + + /// Available preparation level options. + final List levels; + + /// Available equipment options. + final List equipment; + + /// Creates an instance of [ProfileParametersReferences]. + const ProfileParametersReferences({ + required this.goals, + required this.levels, + required this.equipment, + }); + + @override + List get props => [goals, levels, equipment]; +} diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart new file mode 100644 index 00000000..f3dcddd5 --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; + +import 'profile_parameters_gender.dart'; + +/// Bootstrap snapshot used to seed the profile parameters section. +final class ProfileParametersSnapshot extends Equatable { + /// Goal display name. + final String goal; + + /// Selected gender. + final ProfileParametersGender gender; + + /// User age in years. + final int age; + + /// User weight in kilograms. + final double weight; + + /// User height in centimeters. + final int height; + + /// Equipment display name. + final String equipment; + + /// Preparation level display name. + final String level; + + /// Creates an instance of [ProfileParametersSnapshot]. + const ProfileParametersSnapshot({ + required this.goal, + required this.gender, + required this.age, + required this.weight, + required this.height, + required this.equipment, + required this.level, + }); + + @override + List get props => [goal, gender, age, weight, height, equipment, level]; +} diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_submit_payload.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_submit_payload.dart new file mode 100644 index 00000000..ed11bd0a --- /dev/null +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_submit_payload.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +import 'profile_parameters_gender.dart'; + +/// Typed form payload submitted from the profile parameters section. +final class ProfileParametersSubmitPayload extends Equatable { + /// Selected goal identifier. + final int goalId; + + /// Selected gender value. + final ProfileParametersGender gender; + + /// User age in years. + final int age; + + /// User weight in kilograms. + final double weight; + + /// User height in centimeters. + final int height; + + /// Selected equipment identifier. + final int equipmentId; + + /// Selected preparation level identifier. + final int levelId; + + /// Weekly training goal. + final int weeklyGoal; + + /// Creates an instance of [ProfileParametersSubmitPayload]. + const ProfileParametersSubmitPayload({ + required this.goalId, + required this.gender, + required this.age, + required this.weight, + required this.height, + required this.equipmentId, + required this.levelId, + required this.weeklyGoal, + }); + + @override + List get props => [ + goalId, + gender, + age, + weight, + height, + equipmentId, + levelId, + weeklyGoal, + ]; +} diff --git a/lib/features/profile/domain/repositories/profile_parameters_repository.dart b/lib/features/profile/domain/repositories/profile_parameters_repository.dart new file mode 100644 index 00000000..838cd605 --- /dev/null +++ b/lib/features/profile/domain/repositories/profile_parameters_repository.dart @@ -0,0 +1,21 @@ +import '../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/profile_parameters/profile_parameters_data.dart'; +import '../entities/profile_parameters/profile_parameters_references.dart'; +import '../entities/profile_parameters/profile_parameters_submit_payload.dart'; + +/// Repository interface for the profile parameters section. +abstract interface class ProfileParametersRepository { + /// Returns references required by the profile parameters form. + Future> getReferences(); + + /// Returns canonical authenticated parameters for the current user. + Future> getCurrentParameters(); + + /// Saves all changed profile parameters and returns the refreshed payload. + Future> saveParameters({ + required ProfileParametersData currentParameters, + required int currentWeeklyGoal, + required ProfileParametersSubmitPayload payload, + }); +} diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index dabd6d15..f520d2d2 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -2,6 +2,7 @@ import '../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; import '../entities/profile_phase_snapshot.dart'; +import '../entities/profile_parameters/profile_parameters_snapshot.dart'; import '../entities/profile_stats_history_snapshot.dart'; /// Repository interface for authenticated profile operations. @@ -15,6 +16,9 @@ abstract interface class ProfileRepository { /// Returns the current phase snapshot for the current phase section. Future> getPhaseSnapshot(); + /// Returns the current parameters snapshot for the profile parameters section. + Future> getParametersSnapshot(); + /// Updates the current user profile and returns the canonical refreshed user payload. Future> updateUser({ required User currentUser, diff --git a/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart b/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart new file mode 100644 index 00000000..156aa6b9 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart @@ -0,0 +1,226 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_data.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_gender.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_references.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_snapshot.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_submit_payload.dart'; +import '../../domain/repositories/profile_parameters_repository.dart'; + +part 'profile_parameters_cubit.freezed.dart'; +part 'profile_parameters_state.dart'; + +/// Cubit that manages the editable profile parameters section. +final class ProfileParametersCubit extends Cubit { + final ProfileParametersRepository _repository; + + /// Creates an instance of [ProfileParametersCubit]. + ProfileParametersCubit(this._repository) : super(const ProfileParametersState()); + + /// Stores bootstrap snapshot values from `/profile`. + void setBootstrapSnapshot(ProfileParametersSnapshot? snapshot) { + if (isClosed || state.bootstrapSnapshot == snapshot) return; + + emit( + state.copyWith( + bootstrapSnapshot: snapshot, + selectedGender: state.selectedGender ?? state.currentParameters?.gender ?? snapshot?.gender, + ), + ); + } + + /// Loads references and canonical current parameters. + Future loadInitial() async { + await _load(force: false); + } + + /// Reloads the full parameters form state. + Future reload() async { + await _load(force: true); + } + + /// Selects the current training goal. + void selectGoal(int goalId) { + if (state.isSubmitting) return; + + emit( + state.copyWith( + selectedGoalId: goalId, + failure: null, + ), + ); + } + + /// Selects the current gender value. + void selectGender(ProfileParametersGender gender) { + if (state.isSubmitting) return; + + emit( + state.copyWith( + selectedGender: gender, + failure: null, + ), + ); + } + + /// Selects the current equipment option. + void selectEquipment(int equipmentId) { + if (state.isSubmitting) return; + + emit( + state.copyWith( + selectedEquipmentId: equipmentId, + failure: null, + ), + ); + } + + /// Selects the current preparation level. + void selectLevel(int levelId) { + if (state.isSubmitting) return; + + emit( + state.copyWith( + selectedLevelId: levelId, + failure: null, + ), + ); + } + + /// Clears the current failure after it was handled by the UI. + void clearFailure() { + emit(state.copyWith(failure: null)); + } + + /// Resets the pending workouts reload request after the UI handled it. + void consumeWorkoutsReloadRequest() { + if (!state.shouldReloadWorkouts) return; + emit(state.copyWith(shouldReloadWorkouts: false)); + } + + /// Saves the profile parameters form and marks workouts for reload when needed. + Future submit({ + required ProfileParametersSubmitPayload payload, + required int currentWeeklyGoal, + }) async { + final currentParameters = state.currentParameters; + if (state.isLoading || state.isSubmitting || currentParameters == null) return; + final hasChanges = + payload.goalId != currentParameters.goalId || + payload.gender != currentParameters.gender || + payload.age != currentParameters.age || + payload.weight != currentParameters.weight || + payload.height != currentParameters.height || + payload.equipmentId != currentParameters.equipmentId || + payload.levelId != currentParameters.levelId || + payload.weeklyGoal != currentWeeklyGoal; + final shouldReloadWorkouts = + payload.goalId != currentParameters.goalId || + payload.equipmentId != currentParameters.equipmentId || + payload.levelId != currentParameters.levelId; + if (!hasChanges) return; + + emit( + state.copyWith( + isSubmitting: true, + shouldReloadWorkouts: false, + failure: null, + ), + ); + + final result = await _repository.saveParameters( + currentParameters: currentParameters, + currentWeeklyGoal: currentWeeklyGoal, + payload: payload, + ); + if (isClosed) return; + + switch (result) { + case Success(data: final data): + emit( + state.copyWith( + isSubmitting: false, + shouldReloadWorkouts: shouldReloadWorkouts, + currentParameters: data, + bootstrapSnapshot: _toSnapshot(data), + selectedGoalId: data.goalId, + selectedGender: data.gender, + selectedEquipmentId: data.equipmentId, + selectedLevelId: data.levelId, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isSubmitting: false, + shouldReloadWorkouts: false, + failure: error, + ), + ); + } + } + + Future _load({required bool force}) async { + final hasLoadedState = state.references != null && state.currentParameters != null; + if (state.isSubmitting) return; + if (!force && (state.isLoading || hasLoadedState)) return; + + emit( + state.copyWith( + isLoading: true, + failure: null, + ), + ); + + final referencesFuture = _repository.getReferences(); + final currentParametersFuture = _repository.getCurrentParameters(); + + final referencesResult = await referencesFuture; + final currentParametersResult = await currentParametersFuture; + if (isClosed) return; + + final nextReferences = switch (referencesResult) { + Success(data: final references) => references, + Failure() => state.references, + }; + final nextCurrentParameters = switch (currentParametersResult) { + Success(data: final parameters) => parameters, + Failure() => state.currentParameters, + }; + final nextFailure = switch ((referencesResult, currentParametersResult)) { + (Failure(error: final error), _) => error, + (_, Failure(error: final error)) => error, + _ => null, + }; + + emit( + state.copyWith( + isLoading: false, + references: nextReferences, + currentParameters: nextCurrentParameters, + bootstrapSnapshot: nextCurrentParameters == null + ? state.bootstrapSnapshot + : _toSnapshot(nextCurrentParameters), + selectedGoalId: nextCurrentParameters?.goalId ?? state.selectedGoalId, + selectedGender: nextCurrentParameters?.gender ?? state.selectedGender, + selectedEquipmentId: nextCurrentParameters?.equipmentId ?? state.selectedEquipmentId, + selectedLevelId: nextCurrentParameters?.levelId ?? state.selectedLevelId, + failure: nextFailure, + ), + ); + } + + ProfileParametersSnapshot _toSnapshot(ProfileParametersData data) => ProfileParametersSnapshot( + goal: data.goalName, + gender: data.gender, + age: data.age, + weight: data.weight, + height: data.height, + equipment: data.equipmentName, + level: data.levelName, + ); +} diff --git a/lib/features/profile/presentation/cubits/profile_parameters_state.dart b/lib/features/profile/presentation/cubits/profile_parameters_state.dart new file mode 100644 index 00000000..6532b78e --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_parameters_state.dart @@ -0,0 +1,20 @@ +part of 'profile_parameters_cubit.dart'; + +/// State for [ProfileParametersCubit]. +@freezed +abstract class ProfileParametersState with _$ProfileParametersState { + /// Creates an instance of [ProfileParametersState]. + const factory ProfileParametersState({ + @Default(false) bool isLoading, + @Default(false) bool isSubmitting, + @Default(false) bool shouldReloadWorkouts, + ProfileParametersSnapshot? bootstrapSnapshot, + ProfileParametersReferences? references, + ProfileParametersData? currentParameters, + int? selectedGoalId, + ProfileParametersGender? selectedGender, + int? selectedEquipmentId, + int? selectedLevelId, + ProfileFailure? failure, + }) = _ProfileParametersState; +} diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart index 749a3524..be490315 100644 --- a/lib/features/profile/presentation/cubits/profile_user_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart @@ -5,6 +5,7 @@ import '../../../../../core/failures/feature/profile/profile_failure.dart'; import '../../../../../core/result/result.dart'; import '../../../auth/domain/entities/user.dart'; import '../../domain/entities/profile_phase_snapshot.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_snapshot.dart'; import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; @@ -41,6 +42,8 @@ final class ProfileUserCubit extends Cubit { if (isClosed) return; final phaseResult = await _repository.getPhaseSnapshot(); if (isClosed) return; + final parametersResult = await _repository.getParametersSnapshot(); + if (isClosed) return; final historySnapshot = switch (historyResult) { Success(data: final snapshot) => snapshot, @@ -50,12 +53,17 @@ final class ProfileUserCubit extends Cubit { Success(data: final snapshot) => snapshot, Failure() => state.phaseSnapshot, }; + final parametersSnapshot = switch (parametersResult) { + Success(data: final snapshot) => snapshot, + Failure() => state.parametersSnapshot, + }; emit( state.copyWith( isLoading: false, user: user, historySnapshot: historySnapshot, phaseSnapshot: phaseSnapshot, + parametersSnapshot: parametersSnapshot, failure: null, ), ); diff --git a/lib/features/profile/presentation/cubits/profile_user_state.dart b/lib/features/profile/presentation/cubits/profile_user_state.dart index ca0fb057..8b2c6505 100644 --- a/lib/features/profile/presentation/cubits/profile_user_state.dart +++ b/lib/features/profile/presentation/cubits/profile_user_state.dart @@ -9,6 +9,7 @@ abstract class ProfileUserState with _$ProfileUserState { User? user, ProfileStatsHistorySnapshot? historySnapshot, ProfilePhaseSnapshot? phaseSnapshot, + ProfileParametersSnapshot? parametersSnapshot, ProfileFailure? failure, }) = _ProfileUserState; } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index ff11fea4..0d0e63b8 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -14,11 +14,13 @@ import '../../../../../uikit/themes/colors/app_color_theme.dart'; import '../../../../../uikit/themes/text/app_text_theme.dart'; import '../../../auth/domain/entities/user.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../cubits/profile_parameters_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; import '../widgets/current_phase_section_widget.dart'; import '../widgets/edit_profile_dialog.dart'; +import '../widgets/profile_parameters_section_widget.dart'; import '../widgets/stats/profile_history_dialog.dart'; import '../widgets/stats/stats_section_widget.dart'; import '../widgets/user_section_widget.dart'; @@ -69,12 +71,16 @@ class ProfilePage extends StatelessWidget { ], ), body: BlocListener( - listenWhen: (previous, current) => previous.historySnapshot != current.historySnapshot, + listenWhen: (previous, current) => + previous.historySnapshot != current.historySnapshot || + previous.parametersSnapshot != current.parametersSnapshot, listener: (context, state) { final historySnapshot = state.historySnapshot; - if (historySnapshot == null) return; + if (historySnapshot != null) { + context.read().setHistorySnapshot(historySnapshot); + } - context.read().setHistorySnapshot(historySnapshot); + context.read().setBootstrapSnapshot(state.parametersSnapshot); }, child: BlocBuilder( builder: (context, state) { @@ -114,6 +120,8 @@ class ProfilePage extends StatelessWidget { ), const SizedBox(height: 36), const CurrentPhaseSectionWidget(), + const SizedBox(height: 36), + const ProfileParametersSectionWidget(), ], ), ); diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index f6769b9b..f4ebd45b 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -4,8 +4,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../core/di/di.dart'; import '../../../auth/domain/entities/user.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../../domain/repositories/profile_parameters_repository.dart'; import '../../domain/repositories/profile_repository.dart'; import '../../domain/repositories/profile_statistics_repository.dart'; +import '../cubits/profile_parameters_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import 'profile_page.dart'; @@ -36,6 +38,11 @@ class ProfilePageBuilder extends StatelessWidget { di(), )..loadInitial(), ), + BlocProvider( + create: (_) => ProfileParametersCubit( + di(), + )..loadInitial(), + ), ], child: const ProfilePage(), ); diff --git a/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart b/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart new file mode 100644 index 00000000..dd79d001 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart @@ -0,0 +1,799 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/di/di.dart'; +import '../../../../../uikit/buttons/button_size.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/menus/app_selection_dropdown.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../../core/constants/app_assets.dart'; +import '../../../../uikit/images/svg_picture_widget.dart'; +import '../../../fitness_start/presentation/validators/fitness_start_validators.dart'; +import '../../../workouts/overview/presentation/cubits/workouts_overview_cubit.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_data.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_gender.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_option.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_references.dart'; +import '../../domain/entities/profile_parameters/profile_parameters_submit_payload.dart'; +import '../cubits/profile_parameters_cubit.dart'; +import '../cubits/profile_statistics_cubit.dart'; +import '../cubits/profile_user_cubit.dart'; + +enum _ProfileParametersDropdown { + goal, + level, +} + +/// Editable card with the authenticated profile parameters form. +class ProfileParametersSectionWidget extends StatefulWidget { + /// Creates an instance of [ProfileParametersSectionWidget]. + const ProfileParametersSectionWidget({super.key}); + + @override + State createState() => _ProfileParametersSectionWidgetState(); +} + +class _ProfileParametersSectionWidgetState extends State { + final _formKey = GlobalKey(); + final _goalLayerLink = LayerLink(); + final _levelLayerLink = LayerLink(); + final _dropdownTapRegionGroupId = Object(); + final _dropdownOverlayController = OverlayPortalController(); + final _ageController = TextEditingController(); + final _weightController = TextEditingController(); + final _heightController = TextEditingController(); + final _weeklyGoalController = TextEditingController(); + + _ProfileParametersDropdown? _openDropdown; + int? _lastSyncedGoalId; + int? _lastSyncedLevelId; + int? _lastSyncedEquipmentId; + ProfileParametersGender? _lastSyncedGender; + int? _lastSyncedAge; + double? _lastSyncedWeight; + int? _lastSyncedHeight; + int? _lastSyncedWeeklyGoal; + + @override + void dispose() { + _ageController.dispose(); + _weightController.dispose(); + _heightController.dispose(); + _weeklyGoalController.dispose(); + super.dispose(); + } + + void _toggleDropdown(_ProfileParametersDropdown dropdown) { + setState(() { + _openDropdown = _openDropdown == dropdown ? null : dropdown; + }); + if (_openDropdown == null) { + _dropdownOverlayController.hide(); + return; + } + _dropdownOverlayController.show(); + } + + void _closeDropdown() { + if (_openDropdown == null) return; + setState(() => _openDropdown = null); + _dropdownOverlayController.hide(); + } + + void _syncForm(ProfileParametersState state, int weeklyGoal) { + final currentParameters = state.currentParameters; + if (currentParameters != null) { + if (_lastSyncedGoalId != currentParameters.goalId) { + _lastSyncedGoalId = currentParameters.goalId; + } + if (_lastSyncedLevelId != currentParameters.levelId) { + _lastSyncedLevelId = currentParameters.levelId; + } + if (_lastSyncedEquipmentId != currentParameters.equipmentId) { + _lastSyncedEquipmentId = currentParameters.equipmentId; + } + if (_lastSyncedGender != currentParameters.gender) { + _lastSyncedGender = currentParameters.gender; + } + if (_lastSyncedAge != currentParameters.age) { + _ageController.text = '${currentParameters.age}'; + _lastSyncedAge = currentParameters.age; + } + if (_lastSyncedWeight != currentParameters.weight) { + _weightController.text = _formatWeight(currentParameters.weight); + _lastSyncedWeight = currentParameters.weight; + } + if (_lastSyncedHeight != currentParameters.height) { + _heightController.text = '${currentParameters.height}'; + _lastSyncedHeight = currentParameters.height; + } + } + + if (_lastSyncedWeeklyGoal != weeklyGoal) { + _weeklyGoalController.text = '$weeklyGoal'; + _lastSyncedWeeklyGoal = weeklyGoal; + } + } + + Future _submit( + ProfileParametersState state, { + required int currentWeeklyGoal, + }) async { + FocusScope.of(context).unfocus(); + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + final selectedGoalId = state.selectedGoalId ?? state.currentParameters?.goalId; + if (selectedGoalId == null) { + await _showFeedback(AppStrings.fitnessStartGoalRequired); + return; + } + + final selectedGender = state.selectedGender ?? state.currentParameters?.gender; + if (selectedGender == null) { + await _showFeedback(AppStrings.fitnessStartGenderRequired); + return; + } + + final selectedEquipmentId = state.selectedEquipmentId ?? state.currentParameters?.equipmentId; + if (selectedEquipmentId == null) { + await _showFeedback(AppStrings.fitnessStartEquipmentRequired); + return; + } + + final selectedLevelId = state.selectedLevelId ?? state.currentParameters?.levelId; + if (selectedLevelId == null) { + await _showFeedback(AppStrings.fitnessStartLevelRequired); + return; + } + + final age = int.tryParse(_ageController.text.trim()); + if (age == null) { + await _showFeedback(AppStrings.fitnessStartAgeInvalid); + return; + } + + final weight = double.tryParse(_weightController.text.trim().replaceAll(',', '.')); + if (weight == null) { + await _showFeedback(AppStrings.fitnessStartWeightInvalid); + return; + } + + final height = int.tryParse(_heightController.text.trim()); + if (height == null) { + await _showFeedback(AppStrings.fitnessStartHeightInvalid); + return; + } + + final weeklyGoal = int.tryParse(_weeklyGoalController.text.trim()); + if (weeklyGoal == null) { + await _showFeedback(AppStrings.profileParametersWeeklyGoalInvalid); + return; + } + + await context.read().submit( + payload: ProfileParametersSubmitPayload( + goalId: selectedGoalId, + gender: selectedGender, + age: age, + weight: weight, + height: height, + equipmentId: selectedEquipmentId, + levelId: selectedLevelId, + weeklyGoal: weeklyGoal, + ), + currentWeeklyGoal: currentWeeklyGoal, + ); + } + + Future _showFeedback(String message) { + return showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: message, + ); + } + + @override + Widget build(BuildContext context) { + final currentWeeklyGoal = context.select( + (cubit) => cubit.state.currentPhaseSummary?.weeklyGoal, + ); + final isLoadingWeeklyGoal = context.select( + (cubit) => cubit.state.isLoadingCurrentPhaseSummary, + ); + final currentWeeklyGoalFailure = context.select( + (cubit) => + cubit.state.currentPhaseSummary == null && cubit.state.currentPhaseSummaryFailure != null, + ); + + return BlocConsumer( + listenWhen: (previous, current) => + (previous.failure != current.failure && + current.failure != null && + current.currentParameters != null && + current.references != null) || + (previous.isSubmitting && !current.isSubmitting && current.failure == null), + listener: (context, state) { + if (state.failure != null) { + _showFeedback(state.failure!.message); + context.read().clearFailure(); + return; + } + + if (!state.isSubmitting) { + _closeDropdown(); + if (state.shouldReloadWorkouts) { + unawaited(di().loadWorkouts()); + context.read().consumeWorkoutsReloadRequest(); + } + unawaited(context.read().reloadCurrentPhaseSummary()); + unawaited(context.read().refresh()); + } + }, + builder: (context, state) { + final references = state.references; + final currentParameters = state.currentParameters; + final isLoadingCard = + (state.isLoading && (references == null || currentParameters == null)) || + (isLoadingWeeklyGoal && currentWeeklyGoal == null); + final hasLoadFailure = + references == null || + currentParameters == null || + currentWeeklyGoal == null || + currentWeeklyGoalFailure; + + if (isLoadingCard) { + return const AppCard( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: _ProfileParametersLoadingState(), + ); + } + + if (hasLoadFailure) { + return AppCard( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: _ProfileParametersErrorState( + onRetryPressed: () { + _closeDropdown(); + context.read().reload(); + context.read().reloadCurrentPhaseSummary(); + }, + ), + ); + } + + _syncForm(state, currentWeeklyGoal); + + return OverlayPortal( + controller: _dropdownOverlayController, + overlayChildBuilder: (context) => Stack( + clipBehavior: Clip.none, + children: [ + if (_openDropdown == _ProfileParametersDropdown.goal) + _DropdownFollower( + link: _goalLayerLink, + groupId: _dropdownTapRegionGroupId, + onTapOutside: _closeDropdown, + child: AppSelectionDropdown( + mode: AppSelectionDropdownMode.single, + constraints: const BoxConstraints( + maxHeight: 220, + minWidth: 240, + maxWidth: 280, + ), + items: references.goals + .map( + (option) => AppSelectionDropdownItem( + value: option.id, + label: option.name, + ), + ) + .toList(growable: false), + selectedValues: { + state.selectedGoalId ?? currentParameters.goalId, + }, + onChanged: (selectedValues) { + if (selectedValues.isEmpty) return; + context.read().selectGoal(selectedValues.first); + _closeDropdown(); + }, + ), + ), + if (_openDropdown == _ProfileParametersDropdown.level) + _DropdownFollower( + link: _levelLayerLink, + groupId: _dropdownTapRegionGroupId, + onTapOutside: _closeDropdown, + child: AppSelectionDropdown( + mode: AppSelectionDropdownMode.single, + constraints: const BoxConstraints( + maxHeight: 220, + minWidth: 240, + maxWidth: 280, + ), + items: references.levels + .map( + (option) => AppSelectionDropdownItem( + value: option.id, + label: option.name, + ), + ) + .toList(growable: false), + selectedValues: { + state.selectedLevelId ?? currentParameters.levelId, + }, + onChanged: (selectedValues) { + if (selectedValues.isEmpty) return; + context.read().selectLevel(selectedValues.first); + _closeDropdown(); + }, + ), + ), + ], + ), + child: TapRegion( + groupId: _dropdownTapRegionGroupId, + onTapOutside: (_) => _closeDropdown(), + child: AppCard( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CompositedTransformTarget( + link: _goalLayerLink, + child: _SelectField( + label: AppStrings.profileParametersGoalLabel, + value: _selectedLabel( + references.goals, + state.selectedGoalId ?? currentParameters.goalId, + ), + onPressed: state.isSubmitting + ? null + : () => _toggleDropdown(_ProfileParametersDropdown.goal), + ), + ), + const SizedBox(height: 12), + _SegmentedOptionsField( + label: AppStrings.fitnessStartGenderLabel, + firstValue: ProfileParametersGender.male, + firstLabel: AppStrings.fitnessStartMaleOption, + secondValue: ProfileParametersGender.female, + secondLabel: AppStrings.fitnessStartFemaleOption, + selectedValue: state.selectedGender ?? currentParameters.gender, + onSelected: state.isSubmitting + ? null + : (value) => context.read().selectGender(value), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _LabeledTextField( + controller: _ageController, + label: AppStrings.fitnessStartAgeLabel, + enabled: !state.isSubmitting, + validator: FitnessStartValidators.age, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 12), + Expanded( + child: _LabeledTextField( + controller: _weightController, + label: AppStrings.fitnessStartWeightLabel, + enabled: !state.isSubmitting, + validator: FitnessStartValidators.weight, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9,.]')), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: _LabeledTextField( + controller: _heightController, + label: AppStrings.fitnessStartHeightLabel, + enabled: !state.isSubmitting, + validator: FitnessStartValidators.height, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + const SizedBox(height: 12), + _buildEquipmentField( + context, + state: state, + references: references, + currentParameters: currentParameters, + ), + const SizedBox(height: 12), + CompositedTransformTarget( + link: _levelLayerLink, + child: _SelectField( + label: AppStrings.profileParametersLevelLabel, + value: _selectedLabel( + references.levels, + state.selectedLevelId ?? currentParameters.levelId, + ), + onPressed: state.isSubmitting + ? null + : () => _toggleDropdown(_ProfileParametersDropdown.level), + ), + ), + const SizedBox(height: 12), + _LabeledTextField( + controller: _weeklyGoalController, + label: AppStrings.profileParametersWeeklyGoalLabel, + enabled: !state.isSubmitting, + validator: _weeklyGoalValidator, + keyboardType: TextInputType.number, + textAlign: TextAlign.start, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 24), + MainButton( + state: state.isSubmitting ? ButtonState.loading : ButtonState.enabled, + onPressed: () => _submit( + state, + currentWeeklyGoal: currentWeeklyGoal, + ), + child: const Text(AppStrings.profileParametersSubmitButton), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +Widget _buildEquipmentField( + BuildContext context, { + required ProfileParametersState state, + required ProfileParametersReferences references, + required ProfileParametersData currentParameters, +}) { + final options = references.equipment; + if (options.isEmpty) { + return const _SelectField( + label: AppStrings.fitnessStartEquipmentLabel, + value: AppStrings.profileParametersEquipmentUnavailable, + onPressed: null, + ); + } + + if (options.length == 1) { + return _SelectField( + label: AppStrings.fitnessStartEquipmentLabel, + value: options.first.name, + onPressed: null, + ); + } + + final selectedEquipmentId = state.selectedEquipmentId ?? currentParameters.equipmentId; + final firstOption = options.first; + final secondOption = options[1]; + + return _SegmentedOptionsField( + label: AppStrings.fitnessStartEquipmentLabel, + firstValue: firstOption.id, + firstLabel: firstOption.name, + secondValue: secondOption.id, + secondLabel: secondOption.name, + selectedValue: selectedEquipmentId, + onSelected: state.isSubmitting + ? null + : (value) => context.read().selectEquipment(value), + ); +} + +final class _DropdownFollower extends StatelessWidget { + final LayerLink link; + final Object groupId; + final VoidCallback onTapOutside; + final Widget child; + + const _DropdownFollower({ + required this.link, + required this.groupId, + required this.onTapOutside, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomLeft, + offset: const Offset(0, 8), + child: TapRegion( + groupId: groupId, + onTapOutside: (_) => onTapOutside(), + child: child, + ), + ); + } +} + +final class _ProfileParametersLoadingState extends StatelessWidget { + const _ProfileParametersLoadingState(); + + @override + Widget build(BuildContext context) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } +} + +final class _ProfileParametersErrorState extends StatelessWidget { + final VoidCallback onRetryPressed; + + const _ProfileParametersErrorState({ + required this.onRetryPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.profileParametersLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ); + } +} + +final class _SelectField extends StatelessWidget { + final String label; + final String value; + final VoidCallback? onPressed; + + const _SelectField({ + required this.label, + required this.value, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final isEnabled = onPressed != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.label.copyWith(color: colorTheme.hint), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsetsGeometry.only(right: 13), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isEnabled ? colorTheme.outline : colorTheme.disabled, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + value, + style: textTheme.body.copyWith( + color: colorTheme.hint, + ), + ), + ), + const SvgPictureWidget.icon(AppAssets.iconArrowDown), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +final class _SegmentedOptionsField extends StatelessWidget { + final String label; + final T firstValue; + final String firstLabel; + final T secondValue; + final String secondLabel; + final T selectedValue; + final ValueChanged? onSelected; + + const _SegmentedOptionsField({ + required this.label, + required this.firstValue, + required this.firstLabel, + required this.secondValue, + required this.secondLabel, + required this.selectedValue, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final buttonState = onSelected == null ? ButtonState.disabled : ButtonState.enabled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.label.copyWith(color: colorTheme.hint), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: OptionButton( + size: ButtonSize.small, + state: buttonState, + isSelected: selectedValue == firstValue, + onPressed: () => onSelected?.call(firstValue), + child: Text(firstLabel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OptionButton( + size: ButtonSize.small, + state: buttonState, + isSelected: selectedValue == secondValue, + onPressed: () => onSelected?.call(secondValue), + child: Text(secondLabel), + ), + ), + ], + ), + ], + ); + } +} + +final class _LabeledTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + final bool enabled; + final String? Function(String?)? validator; + final TextInputType keyboardType; + final List? inputFormatters; + final TextAlign textAlign; + + const _LabeledTextField({ + required this.controller, + required this.label, + required this.enabled, + required this.validator, + required this.keyboardType, + this.textAlign = TextAlign.center, + this.inputFormatters, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final border = OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide(color: colorTheme.disabled.withValues(alpha: 0.6)), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.label.copyWith(color: colorTheme.hint), + ), + const SizedBox(height: 4), + TextFormField( + controller: controller, + enabled: enabled, + validator: validator, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + textAlign: textAlign, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.all(12), + border: border, + enabledBorder: border, + disabledBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide(color: colorTheme.disabled), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide(color: colorTheme.primary.withValues(alpha: 0.8)), + ), + ), + ), + ], + ); + } +} + +String _selectedLabel(List options, int selectedId) { + for (final option in options) { + if (option.id == selectedId) return option.name; + } + return options.first.name; +} + +String _formatWeight(double weight) { + if (weight == weight.roundToDouble()) { + return '${weight.toInt()}'; + } + return weight.toString().replaceAll('.', ','); +} + +String? _weeklyGoalValidator(String? value) { + final trimmedValue = value?.trim() ?? ''; + if (trimmedValue.isEmpty) { + return AppStrings.profileParametersWeeklyGoalRequired; + } + + final parsedValue = int.tryParse(trimmedValue); + if (parsedValue == null) { + return AppStrings.profileParametersWeeklyGoalInvalid; + } + if (parsedValue < 1 || parsedValue > 7) { + return AppStrings.profileParametersWeeklyGoalRange; + } + + return null; +} diff --git a/lib/features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart b/lib/features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart index 3241acb8..882f694a 100644 --- a/lib/features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart +++ b/lib/features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart @@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../core/di/di.dart'; -import '../../domain/repositories/workouts_overview_repository.dart'; import '../cubits/workouts_overview_cubit.dart'; import 'workouts_overview_page.dart'; @@ -13,8 +12,8 @@ class WorkoutsOverviewPageBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => WorkoutsOverviewCubit(di())..loadWorkouts(), + return BlocProvider.value( + value: di()..loadWorkouts(), child: const WorkoutsOverviewPage(), ); } diff --git a/lib/uikit/buttons/option_button.dart b/lib/uikit/buttons/option_button.dart index 99ec8022..a4400c4f 100644 --- a/lib/uikit/buttons/option_button.dart +++ b/lib/uikit/buttons/option_button.dart @@ -49,6 +49,14 @@ class OptionButton extends StatelessWidget { ButtonSize.large => textTheme.bodyMedium, ButtonSize.small => textTheme.body, }; + final shape = switch (size) { + ButtonSize.large => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ButtonSize.small => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + }; return SizedBox( width: double.infinity, @@ -61,9 +69,7 @@ class OptionButton extends StatelessWidget { tapTargetSize: MaterialTapTargetSize.shrinkWrap, textStyle: textStyle, foregroundColor: colorTheme.onSurface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), + shape: shape, backgroundColor: colorTheme.surface, shadowColor: Colors.transparent, overlayColor: Colors.transparent, diff --git a/test/features/profile/data/repositories/profile_parameters_repository_impl_test.dart b/test/features/profile/data/repositories/profile_parameters_repository_impl_test.dart new file mode 100644 index 00000000..5240095d --- /dev/null +++ b/test/features/profile/data/repositories/profile_parameters_repository_impl_test.dart @@ -0,0 +1,405 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/profile/data/dto/params/profile_parameters_request_dto.dart'; +import 'package:moveup_flutter/features/profile/data/remote/profile_parameters_api_client.dart'; +import 'package:moveup_flutter/features/profile/data/repositories/profile_parameters_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_parameters_repository.dart'; + +import '../../support/profile_dto_fixtures.dart'; +import '../../support/profile_parameters_dto_fixtures.dart'; +import 'profile_parameters_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockProfileParametersApiClient apiClient; + late ProfileParametersRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockProfileParametersApiClient(); + repository = ProfileParametersRepositoryImpl(logger, apiClient); + }); + + group('ProfileParametersRepositoryImpl', () { + group('getReferences()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when( + apiClient.getReferences(), + ).thenAnswer((_) async => createProfileParametersReferencesResponseDto()); + + // Act + final result = await repository.getReferences(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, testProfileParametersReferences); + + verify(apiClient.getReferences()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/user-parameters/references', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getReferences()).thenThrow(exception); + + // Act + final result = await repository.getReferences(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getReferences()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getReferences()).thenThrow(exception); + + // Act + final result = await repository.getReferences(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getReferences()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('getCurrentParameters()', () { + test('returns success(data) when api succeeds', () async { + // Arrange + when( + apiClient.getCurrentParameters(), + ).thenAnswer((_) async => createProfileCurrentParametersResponseDto()); + + // Act + final result = await repository.getCurrentParameters(); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, testProfileParametersData); + + verify(apiClient.getCurrentParameters()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/user-parameters/me', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getCurrentParameters()).thenThrow(exception); + + // Act + final result = await repository.getCurrentParameters(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getCurrentParameters()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getCurrentParameters()).thenThrow(exception); + + // Act + final result = await repository.getCurrentParameters(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getCurrentParameters()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('saveParameters()', () { + test('returns refreshed data when only goal changed', () async { + // Arrange + when(apiClient.saveGoal(any)).thenAnswer((_) async {}); + when(apiClient.getCurrentParameters()).thenAnswer( + (_) async => createProfileCurrentParametersResponseDto( + data: createProfileCurrentParametersDto(goalId: testProfileParametersUpdatedGoalId), + ), + ); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.goalId, testProfileParametersUpdatedGoalId); + expect(result.success!.goalName, testProfileParametersUpdatedGoalName); + + final captured = + verify(apiClient.saveGoal(captureAny)).captured.single as SaveProfileGoalRequestDto; + expect(captured.goalId, testProfileParametersUpdatedGoalId); + verify(apiClient.getCurrentParameters()).called(1); + verifyNever(apiClient.saveAnthropometry(any)); + verifyNever(apiClient.saveLevel(any)); + verifyNever(apiClient.updateWeeklyGoal(any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns refreshed data when only anthropometry changed', () async { + // Arrange + when(apiClient.saveAnthropometry(any)).thenAnswer((_) async {}); + when(apiClient.getCurrentParameters()).thenAnswer( + (_) async => createProfileCurrentParametersResponseDto( + data: createProfileCurrentParametersDto( + equipmentId: testProfileParametersUpdatedEquipmentId, + gender: 'male', + age: 24, + weight: 73.5, + height: 180, + ), + ), + ); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + gender: ProfileParametersGender.male, + age: 24, + weight: 73.5, + height: 180, + equipmentId: testProfileParametersUpdatedEquipmentId, + ), + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.gender, ProfileParametersGender.male); + expect(result.success!.equipmentId, testProfileParametersUpdatedEquipmentId); + + final captured = + verify( + apiClient.saveAnthropometry(captureAny), + ).captured.single + as SaveProfileAnthropometryRequestDto; + expect(captured.gender, 'male'); + expect(captured.age, 24); + expect(captured.weight, 73.5); + expect(captured.height, 180); + expect(captured.equipmentId, testProfileParametersUpdatedEquipmentId); + verify(apiClient.getCurrentParameters()).called(1); + verifyNever(apiClient.saveGoal(any)); + verifyNever(apiClient.saveLevel(any)); + verifyNever(apiClient.updateWeeklyGoal(any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns refreshed data when only level changed', () async { + // Arrange + when(apiClient.saveLevel(any)).thenAnswer((_) async {}); + when(apiClient.getCurrentParameters()).thenAnswer( + (_) async => createProfileCurrentParametersResponseDto( + data: createProfileCurrentParametersDto(levelId: testProfileParametersUpdatedLevelId), + ), + ); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + levelId: testProfileParametersUpdatedLevelId, + ), + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success!.levelId, testProfileParametersUpdatedLevelId); + expect(result.success!.levelName, testProfileParametersUpdatedLevelName); + + final captured = + verify(apiClient.saveLevel(captureAny)).captured.single as SaveProfileLevelRequestDto; + expect(captured.levelId, testProfileParametersUpdatedLevelId); + verify(apiClient.getCurrentParameters()).called(1); + verifyNever(apiClient.saveGoal(any)); + verifyNever(apiClient.saveAnthropometry(any)); + verifyNever(apiClient.updateWeeklyGoal(any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns refreshed data when only weekly goal changed', () async { + // Arrange + when(apiClient.updateWeeklyGoal(any)).thenAnswer((_) async {}); + when( + apiClient.getCurrentParameters(), + ).thenAnswer((_) async => createProfileCurrentParametersResponseDto()); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + weeklyGoal: testProfileParametersUpdatedWeeklyGoal, + ), + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, testProfileParametersData); + + final captured = + verify(apiClient.updateWeeklyGoal(captureAny)).captured.single + as UpdateProfileWeeklyGoalRequestDto; + expect(captured.weeklyGoal, testProfileParametersUpdatedWeeklyGoal); + verify(apiClient.getCurrentParameters()).called(1); + verifyNever(apiClient.saveGoal(any)); + verifyNever(apiClient.saveAnthropometry(any)); + verifyNever(apiClient.saveLevel(any)); + verifyNoMoreInteractions(apiClient); + }); + + test('calls changed endpoints in fixed order for combined changes', () async { + // Arrange + when(apiClient.saveGoal(any)).thenAnswer((_) async {}); + when(apiClient.saveAnthropometry(any)).thenAnswer((_) async {}); + when(apiClient.saveLevel(any)).thenAnswer((_) async {}); + when(apiClient.updateWeeklyGoal(any)).thenAnswer((_) async {}); + when( + apiClient.getCurrentParameters(), + ).thenAnswer((_) async => createProfileCurrentParametersResponseDto()); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + gender: ProfileParametersGender.male, + age: 24, + weight: 73.5, + height: 180, + equipmentId: testProfileParametersUpdatedEquipmentId, + levelId: testProfileParametersUpdatedLevelId, + weeklyGoal: testProfileParametersUpdatedWeeklyGoal, + ), + ); + + // Assert + expect(result.isSuccess, isTrue); + + verifyInOrder([ + apiClient.saveGoal(any), + apiClient.saveAnthropometry(any), + apiClient.saveLevel(any), + apiClient.updateWeeklyGoal(any), + apiClient.getCurrentParameters(), + ]); + verifyNoMoreInteractions(apiClient); + }); + + test('returns current data without requests when nothing changed', () async { + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: testProfileParametersSubmitPayload, + ); + + // Assert + expect(result.isSuccess, isTrue); + expect(result.success, testProfileParametersData); + verifyNever(apiClient.saveGoal(any)); + verifyNever(apiClient.saveAnthropometry(any)); + verifyNever(apiClient.saveLevel(any)); + verifyNever(apiClient.updateWeeklyGoal(any)); + verifyNever(apiClient.getCurrentParameters()); + }); + + test('returns ProfileRequestFailure when api fails', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/user-parameters/goal', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.saveGoal(any)).thenThrow(exception); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveGoal(any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.saveGoal(any)).thenThrow(exception); + + // Act + final result = await repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveGoal(any)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + }); +} diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 531c0a85..85436801 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -10,6 +10,8 @@ import 'package:moveup_flutter/features/profile/data/dto/change_password_request import 'package:moveup_flutter/features/profile/data/dto/update_profile_request_dto.dart'; import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart'; import 'package:moveup_flutter/features/profile/data/repositories/profile_repository_impl.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; @@ -344,6 +346,30 @@ void main() { verifyNoMoreInteractions(apiClient); }); + test('warms parameters cache from the same /profile response', () async { + // Arrange + when(apiClient.getProfile()).thenAnswer( + (_) async => createProfileUserResponseDto( + subscriptions: createProfileSubscriptionsDto(), + workouts: createProfileWorkoutsDto(), + tests: createProfileTestsDto(), + parameters: createProfileParametersInProfileDto(), + ), + ); + + // Act + final historyResult = await repository.getStatsHistorySnapshot(); + final parametersResult = await repository.getParametersSnapshot(); + + // Assert + expect(historyResult.isSuccess, isTrue); + expect(parametersResult.isSuccess, isTrue); + expect(parametersResult.success, createProfileParametersSnapshot()); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + test('returns ProfileRequestFailure when api returns server error', () async { // Arrange final exception = createProfileDioBadResponseException( @@ -480,6 +506,130 @@ void main() { }); }); + group('getParametersSnapshot', () { + test('returns snapshot from cache after getUser succeeds', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + parameters: createProfileParametersInProfileDto(), + ), + ); + + // Act + final getUserResult = await repository.getUser(); + final parametersResult = await repository.getParametersSnapshot(); + + // Assert + expect(getUserResult.isSuccess, isTrue); + expect(parametersResult.isSuccess, isTrue); + expect(parametersResult.success, createProfileParametersSnapshot()); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns parameters snapshot from /profile when cache is empty', () async { + // Arrange + when( + apiClient.getProfile(), + ).thenAnswer( + (_) async => createProfileUserResponseDto( + parameters: createProfileParametersInProfileDto( + goal: 'Снижение веса', + gender: 'male', + age: 24, + weight: 73.5, + height: 180, + equipment: 'Зал', + level: 'Начинающий', + ), + ), + ); + + // Act + final result = await repository.getParametersSnapshot(); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + const ProfileParametersSnapshot( + goal: 'Снижение веса', + gender: ProfileParametersGender.male, + age: 24, + weight: 73.5, + height: 180, + equipment: 'Зал', + level: 'Начинающий', + ), + ); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('does not refetch when server parameters are null', () async { + // Arrange + when(apiClient.getProfile()).thenAnswer( + (_) async => createProfileUserResponseDto(), + ); + + // Act + final firstResult = await repository.getParametersSnapshot(); + final secondResult = await repository.getParametersSnapshot(); + + // Assert + expect(firstResult.isSuccess, isTrue); + expect(firstResult.success, isNull); + expect(secondResult.isSuccess, isTrue); + expect(secondResult.success, isNull); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api returns server error', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getParametersSnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.getProfile()).thenThrow(exception); + + // Act + final result = await repository.getParametersSnapshot(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getProfile()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + group('changePassword', () { test('returns success when api succeeds', () async { // Arrange diff --git a/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart b/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart new file mode 100644 index 00000000..47142a34 --- /dev/null +++ b/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart @@ -0,0 +1,511 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_parameters_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/profile_parameters_cubit.dart'; + +import '../../support/profile_dto_fixtures.dart'; +import '../../support/profile_parameters_dto_fixtures.dart'; +import 'profile_parameters_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileParametersRepository repository; + late ProfileParametersCubit cubit; + + setUp(() { + repository = MockProfileParametersRepository(); + cubit = ProfileParametersCubit(repository); + + provideDummy>( + const Success(testProfileParametersReferences), + ); + provideDummy>( + const Success(testProfileParametersData), + ); + }); + + group('ProfileParametersCubit', () { + const requestFailure = ProfileRequestFailure('request_failed'); + + blocTest( + 'loadInitial emits loading and loaded state when repository succeeds', + setUp: () { + when(repository.getReferences()).thenAnswer( + (_) async => const Success(testProfileParametersReferences), + ); + when(repository.getCurrentParameters()).thenAnswer( + (_) async => const Success(testProfileParametersData), + ); + }, + build: () => cubit, + act: (cubit) => cubit.loadInitial(), + expect: () => const [ + ProfileParametersState(isLoading: true), + ProfileParametersState( + references: testProfileParametersReferences, + currentParameters: testProfileParametersData, + bootstrapSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersGoalName, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + equipment: testProfileParametersEquipmentName, + level: testProfileParametersLevelName, + ), + selectedGoalId: testProfileParametersGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + ], + verify: (_) { + verify(repository.getReferences()).called(1); + verify(repository.getCurrentParameters()).called(1); + }, + ); + + blocTest( + 'loadInitial stores failure when repository fails', + setUp: () { + when(repository.getReferences()).thenAnswer( + (_) async => const Failure(requestFailure), + ); + when(repository.getCurrentParameters()).thenAnswer( + (_) async => const Failure(requestFailure), + ); + }, + build: () => cubit, + act: (cubit) => cubit.loadInitial(), + expect: () => const [ + ProfileParametersState(isLoading: true), + ProfileParametersState(failure: requestFailure), + ], + verify: (_) { + verify(repository.getReferences()).called(1); + verify(repository.getCurrentParameters()).called(1); + }, + ); + + blocTest( + 'setBootstrapSnapshot stores profile bootstrap seed', + build: () => cubit, + act: (cubit) => cubit.setBootstrapSnapshot(createProfileParametersSnapshot()), + expect: () => [ + ProfileParametersState( + bootstrapSnapshot: createProfileParametersSnapshot(), + selectedGender: ProfileParametersGender.female, + ), + ], + ); + + blocTest( + 'setBootstrapSnapshot keeps local selected gender', + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGender: ProfileParametersGender.male, + ), + act: (cubit) => cubit.setBootstrapSnapshot(createProfileParametersSnapshot()), + expect: () => [ + ProfileParametersState( + currentParameters: testProfileParametersData, + bootstrapSnapshot: createProfileParametersSnapshot(), + selectedGender: ProfileParametersGender.male, + ), + ], + ); + + blocTest( + 'selection methods update selected values', + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + ), + act: (cubit) { + cubit.selectGoal(testProfileParametersUpdatedGoalId); + cubit.selectGender(ProfileParametersGender.male); + cubit.selectEquipment(testProfileParametersUpdatedEquipmentId); + cubit.selectLevel(testProfileParametersUpdatedLevelId); + }, + expect: () => const [ + ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersUpdatedGoalId, + ), + ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersUpdatedGoalId, + selectedGender: ProfileParametersGender.male, + ), + ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersUpdatedGoalId, + selectedGender: ProfileParametersGender.male, + selectedEquipmentId: testProfileParametersUpdatedEquipmentId, + ), + ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersUpdatedGoalId, + selectedGender: ProfileParametersGender.male, + selectedEquipmentId: testProfileParametersUpdatedEquipmentId, + selectedLevelId: testProfileParametersUpdatedLevelId, + ), + ], + ); + + blocTest( + 'submit marks workouts for reload when goal changes', + setUp: () => + when( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).thenAnswer( + (_) async => const Success( + ProfileParametersData( + goalId: testProfileParametersUpdatedGoalId, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + goalName: testProfileParametersUpdatedGoalName, + equipmentName: testProfileParametersEquipmentName, + levelName: testProfileParametersLevelName, + ), + ), + ), + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + act: (cubit) => cubit.submit( + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ), + expect: () => const [ + ProfileParametersState( + isSubmitting: true, + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + ProfileParametersState( + shouldReloadWorkouts: true, + currentParameters: ProfileParametersData( + goalId: testProfileParametersUpdatedGoalId, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + goalName: testProfileParametersUpdatedGoalName, + equipmentName: testProfileParametersEquipmentName, + levelName: testProfileParametersLevelName, + ), + bootstrapSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersUpdatedGoalName, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + equipment: testProfileParametersEquipmentName, + level: testProfileParametersLevelName, + ), + selectedGoalId: testProfileParametersUpdatedGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + ], + verify: (_) => verify( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).called(1), + ); + + blocTest( + 'submit keeps workouts reload disabled for anthropometry-only changes', + setUp: () => + when( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + age: testProfileParametersAgeValue + 1, + ), + ), + ).thenAnswer( + (_) async => const Success( + ProfileParametersData( + goalId: testProfileParametersGoalId, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue + 1, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + goalName: testProfileParametersGoalName, + equipmentName: testProfileParametersEquipmentName, + levelName: testProfileParametersLevelName, + ), + ), + ), + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + ), + act: (cubit) => cubit.submit( + payload: createProfileParametersSubmitPayload( + age: testProfileParametersAgeValue + 1, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ), + expect: () => const [ + ProfileParametersState( + isSubmitting: true, + currentParameters: testProfileParametersData, + ), + ProfileParametersState( + currentParameters: ProfileParametersData( + goalId: testProfileParametersGoalId, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue + 1, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + goalName: testProfileParametersGoalName, + equipmentName: testProfileParametersEquipmentName, + levelName: testProfileParametersLevelName, + ), + bootstrapSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersGoalName, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue + 1, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + equipment: testProfileParametersEquipmentName, + level: testProfileParametersLevelName, + ), + selectedGoalId: testProfileParametersGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + ], + verify: (_) => verify( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + age: testProfileParametersAgeValue + 1, + ), + ), + ).called(1), + ); + + blocTest( + 'submit stores failure when repository fails', + setUp: () => when( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).thenAnswer((_) async => const Failure(requestFailure)), + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersGoalId, + ), + act: (cubit) => cubit.submit( + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ), + expect: () => const [ + ProfileParametersState( + isSubmitting: true, + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersGoalId, + ), + ProfileParametersState( + currentParameters: testProfileParametersData, + selectedGoalId: testProfileParametersGoalId, + failure: requestFailure, + ), + ], + verify: (_) => verify( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).called(1), + ); + + blocTest( + 'submit ignores repeated calls while request is in progress', + setUp: () => when( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).thenAnswer((_) async => const Success(testProfileParametersData)), + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + ), + act: (cubit) { + cubit.submit( + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ); + cubit.submit( + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ); + }, + expect: () => const [ + ProfileParametersState( + isSubmitting: true, + currentParameters: testProfileParametersData, + ), + ProfileParametersState( + shouldReloadWorkouts: true, + currentParameters: testProfileParametersData, + bootstrapSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersGoalName, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + equipment: testProfileParametersEquipmentName, + level: testProfileParametersLevelName, + ), + selectedGoalId: testProfileParametersGoalId, + selectedGender: ProfileParametersGender.female, + selectedEquipmentId: testProfileParametersEquipmentId, + selectedLevelId: testProfileParametersLevelId, + ), + ], + verify: (_) => verify( + repository.saveParameters( + currentParameters: testProfileParametersData, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + ), + ).called(1), + ); + + blocTest( + 'submit does nothing when values are unchanged', + build: () => cubit, + seed: () => const ProfileParametersState( + currentParameters: testProfileParametersData, + ), + act: (cubit) => cubit.submit( + payload: testProfileParametersSubmitPayload, + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ), + expect: () => const [], + verify: (_) => verifyNever( + repository.saveParameters( + currentParameters: anyNamed('currentParameters'), + currentWeeklyGoal: anyNamed('currentWeeklyGoal'), + payload: anyNamed('payload'), + ), + ), + ); + + blocTest( + 'submit ignores requests while initial load is in progress', + build: () => cubit, + seed: () => const ProfileParametersState( + isLoading: true, + currentParameters: testProfileParametersData, + ), + act: (cubit) => cubit.submit( + payload: createProfileParametersSubmitPayload( + goalId: testProfileParametersUpdatedGoalId, + ), + currentWeeklyGoal: testProfileParametersWeeklyGoal, + ), + expect: () => const [], + verify: (_) => verifyNever( + repository.saveParameters( + currentParameters: anyNamed('currentParameters'), + currentWeeklyGoal: anyNamed('currentWeeklyGoal'), + payload: anyNamed('payload'), + ), + ), + ); + + blocTest( + 'consumeWorkoutsReloadRequest clears pending reload flag', + build: () => cubit, + seed: () => const ProfileParametersState(shouldReloadWorkouts: true), + act: (cubit) => cubit.consumeWorkoutsReloadRequest(), + expect: () => const [ + ProfileParametersState(), + ], + ); + + blocTest( + 'reload ignores requests while submit is in progress', + build: () => cubit, + seed: () => const ProfileParametersState( + isSubmitting: true, + ), + act: (cubit) => cubit.reload(), + expect: () => const [], + verify: (_) { + verifyNever(repository.getReferences()); + verifyNever(repository.getCurrentParameters()); + }, + ); + }); +} diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart index b75788d9..f473f708 100644 --- a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -5,6 +5,8 @@ import 'package:mockito/mockito.dart'; import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; import 'package:moveup_flutter/core/result/result.dart'; import 'package:moveup_flutter/features/auth/domain/entities/user.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; @@ -41,6 +43,9 @@ void main() { provideDummy>( Success(createProfilePhaseSnapshot()), ); + provideDummy>( + Success(createProfileParametersSnapshot()), + ); }); group('ProfileUserCubit', () { @@ -54,6 +59,9 @@ void main() { when(repository.getPhaseSnapshot()).thenAnswer( (_) async => Success(createProfilePhaseSnapshot()), ); + when(repository.getParametersSnapshot()).thenAnswer( + (_) async => Success(createProfileParametersSnapshot()), + ); }, build: () => cubit, act: (cubit) => cubit.refresh(), @@ -87,12 +95,22 @@ void main() { hasProgress: testProfileHasProgress, currentPhaseName: testProfilePhaseName, ), + parametersSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersGoal, + gender: ProfileParametersGender.female, + age: testProfileParametersAge, + weight: testProfileParametersWeight, + height: testProfileParametersHeight, + equipment: testProfileParametersEquipment, + level: testProfileParametersLevel, + ), ), ], verify: (_) { verify(repository.getUser()).called(1); verify(repository.getStatsHistorySnapshot()).called(1); verify(repository.getPhaseSnapshot()).called(1); + verify(repository.getParametersSnapshot()).called(1); }, ); @@ -106,6 +124,9 @@ void main() { when(repository.getPhaseSnapshot()).thenAnswer( (_) async => Success(createProfilePhaseSnapshot()), ); + when(repository.getParametersSnapshot()).thenAnswer( + (_) async => Success(createProfileParametersSnapshot()), + ); }, build: () => cubit, act: (cubit) { @@ -142,12 +163,22 @@ void main() { hasProgress: testProfileHasProgress, currentPhaseName: testProfilePhaseName, ), + parametersSnapshot: ProfileParametersSnapshot( + goal: testProfileParametersGoal, + gender: ProfileParametersGender.female, + age: testProfileParametersAge, + weight: testProfileParametersWeight, + height: testProfileParametersHeight, + equipment: testProfileParametersEquipment, + level: testProfileParametersLevel, + ), ), ], verify: (_) { verify(repository.getUser()).called(1); verify(repository.getStatsHistorySnapshot()).called(1); verify(repository.getPhaseSnapshot()).called(1); + verify(repository.getParametersSnapshot()).called(1); }, ); diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart index 698c2b44..cd72ab6d 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -6,6 +6,8 @@ import 'package:moveup_flutter/features/profile/data/dto/profile_user_data_dto.d import 'package:moveup_flutter/features/profile/data/dto/profile_user_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_user_response_dto.dart'; import 'package:moveup_flutter/features/profile/data/dto/profile_workout_history_item_dto.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_phase_snapshot.dart'; import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; @@ -31,6 +33,13 @@ const testProfileTestCompletedAt = '2026-03-14 15:20:00'; const testProfilePhaseId = 7; const testProfilePhaseName = 'A1'; const testProfileHasProgress = true; +const testProfileParametersGoal = 'Рост силовых показателей'; +const testProfileParametersGender = 'female'; +const testProfileParametersAge = 18; +const testProfileParametersWeight = 80.0; +const testProfileParametersHeight = 150; +const testProfileParametersEquipment = 'Смешанное'; +const testProfileParametersLevel = 'Профессионал'; /// Test fixture for a shared authenticated [User]. User createProfileUser({ @@ -69,6 +78,7 @@ ProfileUserResponseDto createProfileUserResponseDto({ ProfileWorkoutsDto? workouts, ProfileTestsDto? tests, ProfilePhaseDto? phase, + ProfileParametersInProfileDto? parameters, }) => ProfileUserResponseDto( data: ProfileUserDataDto( user: user ?? createProfileUserDto(), @@ -76,6 +86,7 @@ ProfileUserResponseDto createProfileUserResponseDto({ workouts: workouts, tests: tests, phase: phase, + parameters: parameters, ), ); @@ -97,6 +108,25 @@ ProfileCurrentPhaseDto createProfileCurrentPhaseDto({ name: name, ); +/// Test fixture for [ProfileParametersInProfileDto]. +ProfileParametersInProfileDto createProfileParametersInProfileDto({ + String goal = testProfileParametersGoal, + String gender = testProfileParametersGender, + int age = testProfileParametersAge, + num weight = testProfileParametersWeight, + int height = testProfileParametersHeight, + String equipment = testProfileParametersEquipment, + String level = testProfileParametersLevel, +}) => ProfileParametersInProfileDto( + goal: goal, + gender: gender, + age: age, + weight: weight, + height: height, + equipment: equipment, + level: level, +); + /// Test fixture for [ProfileSubscriptionsDto]. ProfileSubscriptionsDto createProfileSubscriptionsDto({ ActiveProfileSubscriptionDto? active, @@ -211,6 +241,25 @@ ProfilePhaseSnapshot createProfilePhaseSnapshot({ currentPhaseName: currentPhaseName, ); +/// Test fixture for [ProfileParametersSnapshot]. +ProfileParametersSnapshot createProfileParametersSnapshot({ + String goal = testProfileParametersGoal, + ProfileParametersGender gender = ProfileParametersGender.female, + int age = testProfileParametersAge, + double weight = testProfileParametersWeight, + int height = testProfileParametersHeight, + String equipment = testProfileParametersEquipment, + String level = testProfileParametersLevel, +}) => ProfileParametersSnapshot( + goal: goal, + gender: gender, + age: age, + weight: weight, + height: height, + equipment: equipment, + level: level, +); + /// Test fixture for Dio bad response exception. DioException createProfileDioBadResponseException({ required String path, diff --git a/test/features/profile/support/profile_parameters_dto_fixtures.dart b/test/features/profile/support/profile_parameters_dto_fixtures.dart new file mode 100644 index 00000000..16940002 --- /dev/null +++ b/test/features/profile/support/profile_parameters_dto_fixtures.dart @@ -0,0 +1,202 @@ +import 'package:moveup_flutter/features/profile/data/dto/params/profile_current_parameters_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/params/profile_parameters_references_response_dto.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_data.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_option.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_references.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_parameters/profile_parameters_submit_payload.dart'; + +const testProfileParametersGoalId = 1; +const testProfileParametersGoalName = 'Рост силовых показателей'; +const testProfileParametersUpdatedGoalId = 2; +const testProfileParametersUpdatedGoalName = 'Снижение веса'; +const testProfileParametersEquipmentId = 2; +const testProfileParametersEquipmentName = 'Смешанное'; +const testProfileParametersUpdatedEquipmentId = 1; +const testProfileParametersUpdatedEquipmentName = 'Зал'; +const testProfileParametersLevelId = 3; +const testProfileParametersLevelName = 'Профессионал'; +const testProfileParametersUpdatedLevelId = 1; +const testProfileParametersUpdatedLevelName = 'Начинающий'; +const testProfileParametersAgeValue = 18; +const testProfileParametersWeightValue = 80.0; +const testProfileParametersHeightValue = 150; +const testProfileParametersWeeklyGoal = 3; +const testProfileParametersUpdatedWeeklyGoal = 5; + +const testProfileParametersReferences = ProfileParametersReferences( + goals: [ + ProfileParametersOption(id: 1, name: 'Рост силовых показателей'), + ProfileParametersOption(id: 2, name: 'Снижение веса'), + ], + levels: [ + ProfileParametersOption(id: 1, name: 'Начинающий'), + ProfileParametersOption(id: 3, name: 'Профессионал'), + ], + equipment: [ + ProfileParametersOption(id: 1, name: 'Зал'), + ProfileParametersOption(id: 2, name: 'Смешанное'), + ], +); + +const testProfileParametersData = ProfileParametersData( + goalId: testProfileParametersGoalId, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + goalName: testProfileParametersGoalName, + equipmentName: testProfileParametersEquipmentName, + levelName: testProfileParametersLevelName, +); + +const testProfileParametersSubmitPayload = ProfileParametersSubmitPayload( + goalId: testProfileParametersGoalId, + gender: ProfileParametersGender.female, + age: testProfileParametersAgeValue, + weight: testProfileParametersWeightValue, + height: testProfileParametersHeightValue, + equipmentId: testProfileParametersEquipmentId, + levelId: testProfileParametersLevelId, + weeklyGoal: testProfileParametersWeeklyGoal, +); + +/// Test fixture for profile parameters references response DTO. +ProfileParametersReferencesResponseDto createProfileParametersReferencesResponseDto({ + ProfileParametersReferencesDto? data, +}) => ProfileParametersReferencesResponseDto( + data: data ?? createProfileParametersReferencesDto(), +); + +/// Test fixture for references DTO payload. +ProfileParametersReferencesDto createProfileParametersReferencesDto({ + List? goals, + List? levels, + List? equipment, +}) => ProfileParametersReferencesDto( + goals: goals ?? createProfileParametersGoalOptionsDto(), + levels: levels ?? createProfileParametersLevelOptionsDto(), + equipment: equipment ?? createProfileParametersEquipmentOptionsDto(), +); + +/// Test fixture for current parameters response DTO. +ProfileCurrentParametersResponseDto createProfileCurrentParametersResponseDto({ + ProfileCurrentParametersDto? data, +}) => ProfileCurrentParametersResponseDto( + data: data ?? createProfileCurrentParametersDto(), +); + +/// Test fixture for current parameters DTO. +ProfileCurrentParametersDto createProfileCurrentParametersDto({ + int id = 54, + int userId = 336, + int equipmentId = testProfileParametersEquipmentId, + int levelId = testProfileParametersLevelId, + int goalId = testProfileParametersGoalId, + int height = testProfileParametersHeightValue, + double weight = testProfileParametersWeightValue, + int age = testProfileParametersAgeValue, + String gender = 'female', + ProfileCurrentParameterNamedItemDto? goal, + ProfileCurrentParameterNamedItemDto? level, + ProfileCurrentParameterNamedItemDto? equipment, +}) => ProfileCurrentParametersDto( + id: id, + userId: userId, + equipmentId: equipmentId, + levelId: levelId, + goalId: goalId, + height: height, + weight: weight, + age: age, + gender: gender, + goal: + goal ?? + createProfileCurrentParameterNamedItemDto( + id: goalId, + name: goalId == testProfileParametersGoalId + ? testProfileParametersGoalName + : testProfileParametersUpdatedGoalName, + ), + level: + level ?? + createProfileCurrentParameterNamedItemDto( + id: levelId, + name: levelId == testProfileParametersLevelId + ? testProfileParametersLevelName + : testProfileParametersUpdatedLevelName, + ), + equipment: + equipment ?? + createProfileCurrentParameterNamedItemDto( + id: equipmentId, + name: equipmentId == testProfileParametersEquipmentId + ? testProfileParametersEquipmentName + : testProfileParametersUpdatedEquipmentName, + ), +); + +/// Test fixture for nested current parameters named item DTO. +ProfileCurrentParameterNamedItemDto createProfileCurrentParameterNamedItemDto({ + required int id, + required String name, +}) => ProfileCurrentParameterNamedItemDto( + id: id, + name: name, +); + +/// Helper to create changed submit payloads. +ProfileParametersSubmitPayload createProfileParametersSubmitPayload({ + int goalId = testProfileParametersGoalId, + ProfileParametersGender gender = ProfileParametersGender.female, + int age = testProfileParametersAgeValue, + double weight = testProfileParametersWeightValue, + int height = testProfileParametersHeightValue, + int equipmentId = testProfileParametersEquipmentId, + int levelId = testProfileParametersLevelId, + int weeklyGoal = testProfileParametersWeeklyGoal, +}) => ProfileParametersSubmitPayload( + goalId: goalId, + gender: gender, + age: age, + weight: weight, + height: height, + equipmentId: equipmentId, + levelId: levelId, + weeklyGoal: weeklyGoal, +); + +List createProfileParametersGoalOptionsDto() => [ + ProfileParametersReferenceOptionDto( + id: testProfileParametersGoalId, + name: testProfileParametersGoalName, + ), + ProfileParametersReferenceOptionDto( + id: testProfileParametersUpdatedGoalId, + name: testProfileParametersUpdatedGoalName, + ), +]; + +List createProfileParametersLevelOptionsDto() => [ + ProfileParametersReferenceOptionDto( + id: testProfileParametersUpdatedLevelId, + name: testProfileParametersUpdatedLevelName, + ), + ProfileParametersReferenceOptionDto( + id: testProfileParametersLevelId, + name: testProfileParametersLevelName, + ), +]; + +List createProfileParametersEquipmentOptionsDto() => [ + ProfileParametersReferenceOptionDto( + id: testProfileParametersUpdatedEquipmentId, + name: testProfileParametersUpdatedEquipmentName, + ), + ProfileParametersReferenceOptionDto( + id: testProfileParametersEquipmentId, + name: testProfileParametersEquipmentName, + ), +]; From c48a153732019cdaf162c6f355d54927582b031b Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:01:44 +0700 Subject: [PATCH 06/13] feat(profile): add bottom profile actions with authenticated sign-out and legal links (#56) * feat(auth): add authenticated sign-out session cleanup * test(auth): cover signOut cleanup flow * feat(profile): add delete profile api contract * feat(profile-data): implement delete profile repository action * refactor(auth): reuse token cleanup helper in signOut * test(profile-repo): add delete profile repository coverage * feat(profile): add DeleteProfileCubit * test(profile): add delete profile cubit coverage * feat(profile-ui): add profile bottom section actions and legal links * refactor(debug): simplify debug screen placeholder * docs: update CHANGELOG.md * fix(profile-ui): make profile bottom dialogs safer * refactor(router): reuse legal document redirect guard * fix(profile): keep bottom actions available in profile fallback state * fix(profile): make fallback state scrollable with bottom actions * fix(profile): uncomment code lines --- CHANGELOG.md | 2 + lib/core/constants/app_strings.dart | 8 +- lib/core/router/router.dart | 4 + lib/core/router/router_paths.dart | 2 +- .../cubits/auth_session_cubit.dart | 6 + .../debug/presentation/debug_screen.dart | 54 +--- .../data/remote/profile_api_client.dart | 4 + .../repositories/profile_repository_impl.dart | 16 ++ .../repositories/profile_repository.dart | 3 + .../cubits/delete_profile_cubit.dart | 38 +++ .../cubits/delete_profile_state.dart | 17 ++ .../presentation/pages/profile_page.dart | 60 ++-- .../pages/profile_page_builder.dart | 9 + .../profile_bottom_section_widget.dart | 262 ++++++++++++++++++ .../cubits/auth_session_cubit_test.dart | 31 +++ .../profile_repository_impl_test.dart | 55 ++++ .../cubits/delete_profile_cubit_test.dart | 65 +++++ 17 files changed, 566 insertions(+), 70 deletions(-) create mode 100644 lib/features/profile/presentation/cubits/delete_profile_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/delete_profile_state.dart create mode 100644 lib/features/profile/presentation/widgets/profile_bottom_section_widget.dart create mode 100644 test/features/profile/presentation/cubits/delete_profile_cubit_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e288cc9..7c9772d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile statistics section for the authenticated `/profile` tab, including dedicated statistics API client/repository, focused `/profile` history snapshot mapping, statistics Cubit/state flow, chart widgets, selectors, history dialog, and widget coverage for the integrated UI. - Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice. - Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan. +- Add profile bottom section for the authenticated `/profile` tab, including logout and delete-profile confirmation actions plus direct links to the bundled legal documents. ### Changed @@ -37,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile statistics internals were reorganized into dedicated `profile/data/dto/stats` and `profile/presentation/widgets/stats` folders, while repository/cubit/widget tests were aligned with the new structure and shared fixtures. - Shared `OptionButton` now supports canonical `large` and `small` size presets, and the profile statistics plus history-tab controls use the compact 42px variant from the mockups. - Profile dialogs now support per-dialog content padding and optional barrier dismissal, allowing the statistics history modal to match the provided sheet behavior without affecting non-dismissible dialogs. +- The debug route is now a static centered placeholder again and no longer owns a separate logout flow. ### Breaking diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index 2b45018b..537982ae 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -74,6 +74,7 @@ abstract final class AppStrings { static const legalDataProcessingConsentTitle = 'Согласие на обработку персональных данных'; static const legalPublicOfferTitle = 'Публичная оферта'; static const legalDocumentLoadError = 'Не удалось загрузить документ.'; + static const legalDataProcessingConsentProfileTitle = 'Пользовательское соглашение'; // Feedback dialogs. static const feedbackErrorTitle = 'Что-то пошло не так'; @@ -276,6 +277,11 @@ abstract final class AppStrings { static const profileParametersWeeklyGoalInvalid = 'Введите корректное количество тренировок в неделю'; static const profileParametersWeeklyGoalRange = 'Допустимо от 1 до 7 тренировок в неделю'; + static const profileBottomLogoutButton = 'Выйти'; + static const profileBottomDeleteButton = 'Удалить профиль'; + static const profileBottomLogoutTitle = 'Вы уверены, что хотите выйти?'; + static const profileBottomDeleteTitle = 'Вы уверены, что хотите удалить профиль?'; + static const profileBottomDeleteConfirm = 'Удалить'; static const profileStatsTitle = 'Статистика тренировок пользователя'; static const profileStatsHistoryButton = 'История'; static const profileStatsVolumeMode = 'Объём'; @@ -344,7 +350,7 @@ abstract final class AppStrings { } // Debug screen. - static const debugLogoutButton = 'Выйти'; + static const debugScreenTitle = 'Debug Screen'; /// Root tabs. static const testsTab = testsCatalogTitle; diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index f9ec2d0c..fbf7cc71 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -108,6 +108,7 @@ String? _redirectByAuth( final isSplashScreen = state.matchedLocation == AppRoutePaths.splashPath; final isAuthScreen = state.matchedLocation.startsWith(AppRoutePaths.authPrefix); final isFitnessStartScreen = state.matchedLocation.startsWith(AppRoutePaths.fitnessStartPrefix); + final isLegalDocument = state.matchedLocation == AppRoutePaths.legalDocumentPath; return authState.when( initial: () { if (isSplashScreen) return null; @@ -122,6 +123,7 @@ String? _redirectByAuth( if (state.matchedLocation == AppRoutePaths.signUpPath) { return AppRoutePaths.fitnessStartQuizPath; } + if (isLegalDocument) return null; if (isAuthScreen) return null; return AppRoutePaths.signInPath; }, @@ -145,6 +147,7 @@ String? _redirectByAuth( state.matchedLocation == AppRoutePaths.debugPath) { return null; } + if (isLegalDocument) return null; if (_isAuthenticatedAllowedAuthPath(state.matchedLocation)) return null; if (isSplashScreen || isAuthScreen || isFitnessStartScreen) { return AppRoutePaths.workoutsPath; @@ -156,6 +159,7 @@ String? _redirectByAuth( if (state.matchedLocation == AppRoutePaths.signUpPath) { return AppRoutePaths.fitnessStartQuizPath; } + if (isLegalDocument) return null; if (isAuthScreen) return null; return AppRoutePaths.signInPath; }, diff --git a/lib/core/router/router_paths.dart b/lib/core/router/router_paths.dart index 2e2bc767..8d2ada72 100644 --- a/lib/core/router/router_paths.dart +++ b/lib/core/router/router_paths.dart @@ -31,7 +31,7 @@ abstract class AppRoutePaths { static const signUpPath = '$authPrefix/sign-up'; /// Route path for the legal-document page. - static const legalDocumentPath = '$authPrefix/legal-document'; + static const legalDocumentPath = '/legal-document'; /// Route path for the forgot-password page. static const forgotPasswordPath = '$authPrefix/forgot-password'; diff --git a/lib/features/auth/presentation/cubits/auth_session_cubit.dart b/lib/features/auth/presentation/cubits/auth_session_cubit.dart index 4413a5a3..5998aa71 100644 --- a/lib/features/auth/presentation/cubits/auth_session_cubit.dart +++ b/lib/features/auth/presentation/cubits/auth_session_cubit.dart @@ -270,4 +270,10 @@ final class AuthSessionCubit extends Cubit { emit(const AuthSessionState.unauthenticated()); } } + + /// Fully signs out the authenticated user from the current device. + Future signOut() async { + await _clearTokenSafely(); + await clearSession(); + } } diff --git a/lib/features/debug/presentation/debug_screen.dart b/lib/features/debug/presentation/debug_screen.dart index c230c04c..3ee02f74 100644 --- a/lib/features/debug/presentation/debug_screen.dart +++ b/lib/features/debug/presentation/debug_screen.dart @@ -1,13 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/constants/app_strings.dart'; -import '../../../core/di/di.dart'; -import '../../../uikit/dialogs/app_feedback_dialog.dart'; -import '../../auth/domain/repositories/auth_repository.dart'; -import '../../auth/presentation/cubits/auth_session_cubit.dart'; -import '../../auth/presentation/cubits/logout_cubit.dart'; -import 'dart:async'; /// A screen for debugging purposes. class DebugScreen extends StatelessWidget { @@ -16,50 +9,9 @@ class DebugScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: di()), - BlocProvider( - create: (context) => LogoutCubit(di()), - ), - ], - child: BlocListener( - listener: (context, state) { - state.whenOrNull( - succeed: () => unawaited(context.read().clearSession()), - failed: (failure) { - if (failure.message.isNotEmpty) { - showAppFeedbackDialog( - context, - title: AppStrings.feedbackErrorTitle, - message: failure.message, - ); - } - }, - ); - }, - child: Scaffold( - body: Center( - child: BlocBuilder( - builder: (context, state) { - final isInProgress = state.maybeWhen( - inProgress: () => true, - orElse: () => false, - ); - return FilledButton( - onPressed: isInProgress ? null : () => context.read().logout(), - child: isInProgress - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator.adaptive(), - ) - : const Text(AppStrings.debugLogoutButton), - ); - }, - ), - ), - ), + return const Scaffold( + body: Center( + child: Text(AppStrings.debugScreenTitle), ), ); } diff --git a/lib/features/profile/data/remote/profile_api_client.dart b/lib/features/profile/data/remote/profile_api_client.dart index 10fbb31a..5103d880 100644 --- a/lib/features/profile/data/remote/profile_api_client.dart +++ b/lib/features/profile/data/remote/profile_api_client.dart @@ -22,6 +22,10 @@ abstract class ProfileApiClient { @PUT(ApiPaths.profile) Future updateProfile(@Body() UpdateProfileRequestDto request); + /// Deletes the authenticated user profile. + @DELETE(ApiPaths.profile) + Future deleteProfile(); + /// Changes the authenticated user password. @POST(ApiPaths.profileChangePassword) Future changePassword(@Body() ChangePasswordRequestDto request); diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index 34339f85..c1cf971c 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -205,4 +205,20 @@ final class ProfileRepositoryImpl implements ProfileRepository { ); } } + + @override + Future> deleteProfile() async { + try { + await _apiClient.deleteProfile(); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toProfileFailure()); + } catch (e, s) { + _logger.e('DeleteProfile failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } } diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index f520d2d2..345c1450 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -33,4 +33,7 @@ abstract interface class ProfileRepository { required String newPassword, required String newPasswordConfirmation, }); + + /// Deletes the current authenticated profile. + Future> deleteProfile(); } diff --git a/lib/features/profile/presentation/cubits/delete_profile_cubit.dart b/lib/features/profile/presentation/cubits/delete_profile_cubit.dart new file mode 100644 index 00000000..5758af02 --- /dev/null +++ b/lib/features/profile/presentation/cubits/delete_profile_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/profile/profile_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../domain/repositories/profile_repository.dart'; + +part 'delete_profile_cubit.freezed.dart'; +part 'delete_profile_state.dart'; + +/// Cubit that manages the delete-profile flow. +final class DeleteProfileCubit extends Cubit { + final ProfileRepository _repository; + + /// Creates an instance of [DeleteProfileCubit]. + DeleteProfileCubit(this._repository) : super(const DeleteProfileState.initial()); + + /// Attempts to delete the current authenticated profile. + Future deleteProfile() async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const DeleteProfileState.inProgress()); + + final result = await _repository.deleteProfile(); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const DeleteProfileState.succeed()); + case Failure(:final error): + emit(DeleteProfileState.failed(error)); + } + } +} diff --git a/lib/features/profile/presentation/cubits/delete_profile_state.dart b/lib/features/profile/presentation/cubits/delete_profile_state.dart new file mode 100644 index 00000000..07450aad --- /dev/null +++ b/lib/features/profile/presentation/cubits/delete_profile_state.dart @@ -0,0 +1,17 @@ +part of 'delete_profile_cubit.dart'; + +/// States for [DeleteProfileCubit]. +@freezed +class DeleteProfileState with _$DeleteProfileState { + /// Initial idle state. + const factory DeleteProfileState.initial() = _Initial; + + /// Delete request is in progress. + const factory DeleteProfileState.inProgress() = _InProgress; + + /// Profile deletion succeeded. + const factory DeleteProfileState.succeed() = _Succeed; + + /// Profile deletion failed. + const factory DeleteProfileState.failed(ProfileFailure failure) = _Failed; +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 0d0e63b8..aa50a5b2 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -20,6 +20,7 @@ import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; import '../widgets/current_phase_section_widget.dart'; import '../widgets/edit_profile_dialog.dart'; +import '../widgets/profile_bottom_section_widget.dart'; import '../widgets/profile_parameters_section_widget.dart'; import '../widgets/stats/profile_history_dialog.dart'; import '../widgets/stats/stats_section_widget.dart'; @@ -122,6 +123,8 @@ class ProfilePage extends StatelessWidget { const CurrentPhaseSectionWidget(), const SizedBox(height: 36), const ProfileParametersSectionWidget(), + const SizedBox(height: 36), + const ProfileBottomSectionWidget(), ], ), ); @@ -145,6 +148,7 @@ final class _ProfileUserFallbackState extends StatelessWidget { Widget build(BuildContext context) { final textTheme = AppTextTheme.of(context); final colorTheme = AppColorTheme.of(context); + const contentPadding = EdgeInsets.fromLTRB(24, 28, 24, 132); if (isLoading) { return const Center( @@ -155,25 +159,47 @@ final class _ProfileUserFallbackState extends StatelessWidget { ); } - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - AppStrings.profileLoadFailed, - textAlign: TextAlign.center, - style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: contentPadding, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - contentPadding.vertical, ), - const SizedBox(height: 16), - MainButton( - onPressed: onRetryPressed, - child: const Text(AppStrings.retryButton), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.profileLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ), + ), + const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 16), + ProfileBottomSectionWidget(), + ], + ), + ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index f4ebd45b..79873d8b 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -3,10 +3,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../core/di/di.dart'; import '../../../auth/domain/entities/user.dart'; +import '../../../auth/domain/repositories/auth_repository.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../../../auth/presentation/cubits/logout_cubit.dart'; import '../../domain/repositories/profile_parameters_repository.dart'; import '../../domain/repositories/profile_repository.dart'; import '../../domain/repositories/profile_statistics_repository.dart'; +import '../cubits/delete_profile_cubit.dart'; import '../cubits/profile_parameters_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; @@ -43,6 +46,12 @@ class ProfilePageBuilder extends StatelessWidget { di(), )..loadInitial(), ), + BlocProvider( + create: (_) => LogoutCubit(di()), + ), + BlocProvider( + create: (_) => DeleteProfileCubit(di()), + ), ], child: const ProfilePage(), ); diff --git a/lib/features/profile/presentation/widgets/profile_bottom_section_widget.dart b/lib/features/profile/presentation/widgets/profile_bottom_section_widget.dart new file mode 100644 index 00000000..7b20a283 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_bottom_section_widget.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/router/router_paths.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/dialogs/app_action_dialog.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../auth/presentation/cubits/auth_session_cubit.dart'; +import '../../../auth/presentation/cubits/logout_cubit.dart'; +import '../../../auth/presentation/pages/legal_document_type.dart'; +import '../cubits/delete_profile_cubit.dart'; + +/// Bottom profile section with logout/delete actions and legal links. +class ProfileBottomSectionWidget extends StatefulWidget { + /// Creates an instance of [ProfileBottomSectionWidget]. + const ProfileBottomSectionWidget({super.key}); + + @override + State createState() => _ProfileBottomSectionWidgetState(); +} + +class _ProfileBottomSectionWidgetState extends State { + bool _isLogoutDialogOpen = false; + bool _isDeleteDialogOpen = false; + + Future _openLogoutDialog() async { + if (_isLogoutDialogOpen) return; + _isLogoutDialogOpen = true; + final logoutCubit = context.read(); + try { + await showAppActionDialog( + context, + title: AppStrings.profileBottomLogoutTitle, + primaryAction: BlocProvider.value( + value: logoutCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: () => context.read().logout(), + child: const Text(AppStrings.profileBottomLogoutButton), + ); + }, + ), + ), + secondaryAction: BlocProvider.value( + value: logoutCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: _closeActiveDialog, + child: const Text(AppStrings.profileCancelButton), + ); + }, + ), + ), + ); + } finally { + _isLogoutDialogOpen = false; + } + } + + Future _openDeleteDialog() async { + if (_isDeleteDialogOpen) return; + _isDeleteDialogOpen = true; + final deleteProfileCubit = context.read(); + try { + await showAppActionDialog( + context, + title: AppStrings.profileBottomDeleteTitle, + primaryAction: BlocProvider.value( + value: deleteProfileCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: () => context.read().deleteProfile(), + child: const Text(AppStrings.profileBottomDeleteConfirm), + ); + }, + ), + ), + secondaryAction: BlocProvider.value( + value: deleteProfileCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: _closeActiveDialog, + child: const Text(AppStrings.profileCancelButton), + ); + }, + ), + ), + ); + } finally { + _isDeleteDialogOpen = false; + } + } + + void _closeActiveDialog() { + final navigator = Navigator.of(context, rootNavigator: true); + if (!navigator.canPop()) return; + + Route? topRoute; + navigator.popUntil((route) { + topRoute = route; + return true; + }); + if (topRoute is! PopupRoute) return; + + navigator.pop(); + } + + void _openLegalDocument(LegalDocumentType type) => + unawaited(context.push(AppRoutePaths.legalDocumentPath, extra: type)); + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + state.whenOrNull( + succeed: () { + _closeActiveDialog(); + unawaited(context.read().signOut()); + }, + failed: (failure) { + _closeActiveDialog(); + if (failure.message.isEmpty) return; + unawaited( + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ), + ); + }, + ); + }, + ), + BlocListener( + listener: (context, state) { + state.whenOrNull( + succeed: () { + _closeActiveDialog(); + unawaited(context.read().signOut()); + }, + failed: (failure) { + _closeActiveDialog(); + if (failure.message.isEmpty) return; + unawaited( + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ), + ); + }, + ); + }, + ), + ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MainButton( + onPressed: _openLogoutDialog, + child: const Text(AppStrings.profileBottomLogoutButton), + ), + const SizedBox(height: 12), + SecondaryButton( + onPressed: _openDeleteDialog, + child: const Text(AppStrings.profileBottomDeleteButton), + ), + const SizedBox(height: 36), + _LegalLink( + label: AppStrings.legalDataProcessingConsentProfileTitle, + onPressed: () => _openLegalDocument(LegalDocumentType.dataProcessingConsent), + ), + const SizedBox(height: 6), + _LegalLink( + label: AppStrings.legalPublicOfferTitle, + onPressed: () => _openLegalDocument(LegalDocumentType.publicOffer), + ), + const SizedBox(height: 6), + _LegalLink( + label: AppStrings.legalPrivacyPolicyTitle, + onPressed: () => _openLegalDocument(LegalDocumentType.privacyPolicy), + ), + ], + ), + ); + } +} + +final class _LegalLink extends StatelessWidget { + final String label; + final VoidCallback onPressed; + + const _LegalLink({ + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: onPressed, + style: ButtonStyle( + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + minimumSize: const WidgetStatePropertyAll(Size.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colorTheme.disabled; + } + return colorTheme.outline; + }), + textStyle: WidgetStatePropertyAll(textTheme.body), + ), + child: Text( + label, + textAlign: TextAlign.start, + style: textTheme.body, + ), + ), + ); + } +} diff --git a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart index 23347e9e..41d755c2 100644 --- a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart +++ b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart @@ -414,6 +414,37 @@ void main() { }, ); + blocTest( + 'signOut deletes access token and clears session', + setUp: () { + when(tokenStorage.deleteAccessToken()).thenAnswer((_) async {}); + }, + build: () => authSessionCubit, + act: (cubit) => cubit.signOut(), + expect: () => const [AuthSessionState.unauthenticated()], + verify: (_) { + verify(tokenStorage.deleteAccessToken()).called(1); + verify(progressStorage.clear()).called(1); + verify(guestSessionStorage.clear()).called(1); + }, + ); + + blocTest( + 'signOut emits unauthenticated even when token deletion fails', + setUp: () { + when(tokenStorage.deleteAccessToken()).thenThrow(Exception('storage_error')); + }, + build: () => authSessionCubit, + act: (cubit) => cubit.signOut(), + expect: () => const [AuthSessionState.unauthenticated()], + verify: (_) { + verify(tokenStorage.deleteAccessToken()).called(1); + verify(progressStorage.clear()).called(1); + verify(guestSessionStorage.clear()).called(1); + verify(logger.e(any, any, any)).called(1); + }, + ); + blocTest( 'restoreSession works only once when called twice', setUp: () { diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 85436801..5e83eed1 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -704,5 +704,60 @@ void main() { verifyNoMoreInteractions(apiClient); }); }); + + group('deleteProfile', () { + test('returns success when api succeeds', () async { + // Arrange + when(apiClient.deleteProfile()).thenAnswer((_) async {}); + + // Act + final result = await repository.deleteProfile(); + + // Assert + expect(result.isSuccess, isTrue); + + verify(apiClient.deleteProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns ProfileRequestFailure when api returns server error', () async { + // Arrange + final exception = createProfileDioBadResponseException( + path: '/api/profile', + statusCode: 500, + code: 'server_error', + ); + when(apiClient.deleteProfile()).thenThrow(exception); + + // Act + final result = await repository.deleteProfile(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.deleteProfile()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownProfileFailure when unexpected exception occurs', () async { + // Arrange + final exception = Exception('unexpected_error'); + when(apiClient.deleteProfile()).thenThrow(exception); + + // Act + final result = await repository.deleteProfile(); + + // Assert + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.deleteProfile()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); }); } diff --git a/test/features/profile/presentation/cubits/delete_profile_cubit_test.dart b/test/features/profile/presentation/cubits/delete_profile_cubit_test.dart new file mode 100644 index 00000000..322b6cca --- /dev/null +++ b/test/features/profile/presentation/cubits/delete_profile_cubit_test.dart @@ -0,0 +1,65 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/delete_profile_cubit.dart'; + +import 'delete_profile_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockProfileRepository repository; + late DeleteProfileCubit cubit; + + const failure = ProfileRequestFailure('test'); + + setUp(() { + repository = MockProfileRepository(); + cubit = DeleteProfileCubit(repository); + provideDummy>(const Success(null)); + }); + + group('DeleteProfileCubit', () { + blocTest( + 'emits inProgress and succeed when profile deletion succeeds', + setUp: () => when(repository.deleteProfile()).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.deleteProfile(), + expect: () => const [ + DeleteProfileState.inProgress(), + DeleteProfileState.succeed(), + ], + verify: (_) => verify(repository.deleteProfile()).called(1), + ); + + blocTest( + 'emits failed(failure) when profile deletion fails', + setUp: () => when(repository.deleteProfile()).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + act: (cubit) => cubit.deleteProfile(), + expect: () => const [ + DeleteProfileState.inProgress(), + DeleteProfileState.failed(failure), + ], + verify: (_) => verify(repository.deleteProfile()).called(1), + ); + + blocTest( + 'emits inProgress only once when deleteProfile is called twice', + setUp: () => when(repository.deleteProfile()).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.deleteProfile(); + cubit.deleteProfile(); + }, + expect: () => const [ + DeleteProfileState.inProgress(), + DeleteProfileState.succeed(), + ], + verify: (_) => verify(repository.deleteProfile()).called(1), + ); + }); +} From 0df8e997d3b80e109acc61e607caefbbbc97b561 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 3 Apr 2026 15:18:08 +0700 Subject: [PATCH 07/13] Squashed commit of the following: commit f58581bc7b5d4f70725870a0752bd8d03ea11327 Author: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Fri Apr 3 15:14:23 2026 +0700 feat(subscriptions): add authenticated subscriptions catalog, details, and payment flow (#58) * feat(subs-domain): add details and payment contracts * feat(subs-data): implement details lookup and payment command * test(subs-repo): add details and payment repository coverage * feat(subs): add details and payment cubits * test(subs): add details and payment cubit coverage * feat(subs-ui): add subscription details screen * feat(subs-ui): add payment dialog * feat(subs-ui): open details from catalog cards * fix(subs-ui): wire catalog details navigation context * docs: update CHANGELOG.md * feat(subs-data): fetch details by id and map subscriptions failures * feat(subs-ui): align subscription details screen with mockups * feat(subs-payment): polish payment dialog and validators * fix(subs-ui): update card preview number on input * fix(subs-ui): update card preview number on input * fix(subs-payment): reject non-digit cvv values * fix(subs-ui): guard catalog navigation and card accessibility * fix(subs-data): localize subscription not found mapping * fix(subs-payment): sanitize payment failure mapping * fix(subs-ui): obscure CVV field input for security commit 749b4a96d7955532e03b1f399b1e773b9f25853d Author: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Thu Apr 2 13:42:02 2026 +0700 feat(subscriptions): add authenticated subscriptions catalog and profile entrypoint (#57) * feat(subscriptions): add subscriptions api contract * feat(subscriptions-domain): add subscriptions repository contract * feat(subscriptions-data): implement subscriptions repository * test(subscriptions-repo): add subscriptions repository coverage * feat(subscriptions): add subscriptions cubit and state * test(subscriptions): add subscriptions cubit coverage * refactor(subscriptions-domain): extend catalog item model * feat(subscriptions-ui): add subscriptions catalog screen * feat(profile-ui): add subscriptions entry button to profile * docs: update CHANGELOG.md * refactor(images): reuse shared backend image url normalizer * fix(subscriptions): keep only active plans in catalog --- CHANGELOG.md | 4 + assets/icons/card_big.svg | 12 + assets/icons/stats.svg | 3 + assets/images/line_variant.svg | 3 + lib/core/constants/app_assets.dart | 3 + lib/core/constants/app_strings.dart | 55 ++ lib/core/di/di.dart | 15 + .../subscriptions/subscriptions_failure.dart | 47 ++ lib/core/network/api_paths.dart | 6 + .../network/mappers/image_url_mapper.dart | 16 + lib/core/router/router.dart | 24 + lib/core/router/router_paths.dart | 13 + .../presentation/pages/profile_page.dart | 7 +- .../dto/subscription_catalog_item_dto.dart | 45 ++ .../dto/subscription_payment_request_dto.dart | 53 ++ .../data/dto/subscription_response_dto.dart | 19 + .../data/dto/subscriptions_response_dto.dart | 19 + .../mappers/subscription_catalog_mapper.dart | 15 + .../subscription_image_url_mapper.dart | 4 + .../mappers/subscriptions_failure_mapper.dart | 62 ++ .../subscription_payment_api_client.dart | 18 + .../data/remote/subscriptions_api_client.dart | 23 + .../subscriptions_repository_impl.dart | 106 ++++ .../entities/subscription_catalog_item.dart | 37 ++ .../subscription_payment_payload.dart | 47 ++ .../subscriptions_repository.dart | 18 + .../cubits/subscription_details_cubit.dart | 47 ++ .../cubits/subscription_details_state.dart | 17 + .../cubits/subscription_payment_cubit.dart | 41 ++ .../cubits/subscription_payment_state.dart | 17 + .../cubits/subscriptions_cubit.dart | 40 ++ .../cubits/subscriptions_state.dart | 17 + .../pages/subscriptions_catalog_page.dart | 161 ++++++ .../subscriptions_catalog_page_builder.dart | 23 + .../pages/subscriptions_details_page.dart | 392 +++++++++++++ .../subscriptions_details_page_builder.dart | 42 ++ .../subscription_payment_validators.dart | 126 ++++ .../widgets/subscription_card.dart | 172 ++++++ .../widgets/subscription_payment_dialog.dart | 537 ++++++++++++++++++ .../subscription_payment_text_field.dart | 116 ++++ .../mappers/workout_image_url_mapper.dart | 16 +- lib/uikit/cards/app_card.dart | 13 +- lib/uikit/inputs/app_input_field.dart | 24 +- .../subscriptions_failure_mapper_test.dart | 48 ++ .../subscriptions_repository_impl_test.dart | 251 ++++++++ .../subscription_details_cubit_test.dart | 70 +++ .../subscription_payment_cubit_test.dart | 79 +++ .../cubits/subscriptions_cubit_test.dart | 78 +++ .../subscription_payment_validators_test.dart | 217 +++++++ .../support/subscriptions_dto_fixtures.dart | 119 ++++ 50 files changed, 3312 insertions(+), 25 deletions(-) create mode 100644 assets/icons/card_big.svg create mode 100644 assets/icons/stats.svg create mode 100644 assets/images/line_variant.svg create mode 100644 lib/core/failures/feature/subscriptions/subscriptions_failure.dart create mode 100644 lib/core/network/mappers/image_url_mapper.dart create mode 100644 lib/features/subscriptions/data/dto/subscription_catalog_item_dto.dart create mode 100644 lib/features/subscriptions/data/dto/subscription_payment_request_dto.dart create mode 100644 lib/features/subscriptions/data/dto/subscription_response_dto.dart create mode 100644 lib/features/subscriptions/data/dto/subscriptions_response_dto.dart create mode 100644 lib/features/subscriptions/data/mappers/subscription_catalog_mapper.dart create mode 100644 lib/features/subscriptions/data/mappers/subscription_image_url_mapper.dart create mode 100644 lib/features/subscriptions/data/mappers/subscriptions_failure_mapper.dart create mode 100644 lib/features/subscriptions/data/remote/subscription_payment_api_client.dart create mode 100644 lib/features/subscriptions/data/remote/subscriptions_api_client.dart create mode 100644 lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart create mode 100644 lib/features/subscriptions/domain/entities/subscription_catalog_item.dart create mode 100644 lib/features/subscriptions/domain/entities/subscription_payment_payload.dart create mode 100644 lib/features/subscriptions/domain/repositories/subscriptions_repository.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscription_details_cubit.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscription_details_state.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscription_payment_cubit.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscription_payment_state.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscriptions_cubit.dart create mode 100644 lib/features/subscriptions/presentation/cubits/subscriptions_state.dart create mode 100644 lib/features/subscriptions/presentation/pages/subscriptions_catalog_page.dart create mode 100644 lib/features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart create mode 100644 lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart create mode 100644 lib/features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart create mode 100644 lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart create mode 100644 lib/features/subscriptions/presentation/widgets/subscription_card.dart create mode 100644 lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart create mode 100644 lib/features/subscriptions/presentation/widgets/subscription_payment_text_field.dart create mode 100644 test/features/subscriptions/data/mappers/subscriptions_failure_mapper_test.dart create mode 100644 test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart create mode 100644 test/features/subscriptions/presentation/cubits/subscription_details_cubit_test.dart create mode 100644 test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart create mode 100644 test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart create mode 100644 test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart create mode 100644 test/features/subscriptions/support/subscriptions_dto_fixtures.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9772d3..b8f97342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice. - Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan. - Add profile bottom section for the authenticated `/profile` tab, including logout and delete-profile confirmation actions plus direct links to the bundled legal documents. +- Authenticated subscriptions catalog screen, including dedicated subscriptions route, catalog Cubit, card UI with normalized remote images, and a profile CTA for opening available subscription plans. +- Authenticated subscription details and payment flow, including a dedicated details route, catalog-backed item resolution, manual-card payment dialog, and redirect to `/profile` after successful purchase. ### Changed @@ -39,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Shared `OptionButton` now supports canonical `large` and `small` size presets, and the profile statistics plus history-tab controls use the compact 42px variant from the mockups. - Profile dialogs now support per-dialog content padding and optional barrier dismissal, allowing the statistics history modal to match the provided sheet behavior without affecting non-dismissible dialogs. - The debug route is now a static centered placeholder again and no longer owns a separate logout flow. +- `AppCard` now supports an optional fixed height, allowing specialized screens like the subscriptions catalog to match exact card mockups without introducing a forked card component. +- Subscriptions catalog cards now open a dedicated subscription details screen, and `AppInputField` now supports hidden labels for grouped payment-field layouts without requiring widget forks. ### Breaking diff --git a/assets/icons/card_big.svg b/assets/icons/card_big.svg new file mode 100644 index 00000000..8b475f1c --- /dev/null +++ b/assets/icons/card_big.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/stats.svg b/assets/icons/stats.svg new file mode 100644 index 00000000..6260cf89 --- /dev/null +++ b/assets/icons/stats.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/line_variant.svg b/assets/images/line_variant.svg new file mode 100644 index 00000000..ace8c267 --- /dev/null +++ b/assets/images/line_variant.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 96e3600c..c4426b18 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -19,10 +19,13 @@ abstract final class AppAssets { static const iconNormalFace = 'normal_face'; static const iconGoodFace = 'good_face'; static const iconArrowDown = 'arrow_down'; + static const iconStats = 'stats'; + static const iconCardBig = 'card_big'; // Images. static const imageFigure = 'figure'; static const imageLine = 'line'; + static const imageLineVariant = 'line_variant'; // Legal documents. static const legalPrivacyPolicy = 'assets/legal/privacy_policy.txt'; diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index 537982ae..f1e94c2a 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -208,6 +208,60 @@ abstract final class AppStrings { 'У вас уже есть начатая тренировка. Сначала завершите её, чтобы начать новую'; static const workoutsUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + // Subscriptions catalog. + static const subscriptionsCatalogTitle = 'Подписки'; + static const subscriptionsCatalogEmpty = 'Подписки не найдены'; + static const subscriptionsCatalogLoadFailed = 'Не удалось загрузить подписки'; + static const subscriptionsCatalogBenefitTests = + 'Расширенный набор тестов для качественной адаптации'; + static const subscriptionsCatalogBenefitExercises = 'Расширенный набор упражнений'; + static const subscriptionsCatalogRubles = 'рублей'; + static const subscriptionsValidationFailed = 'Проверьте введенные данные и попробуйте снова'; + static const subscriptionsNotFound = 'Подписка не найдена'; + static const subscriptionsUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + static const subscriptionsDetailsLoadFailed = 'Не удалось загрузить подписку'; + static const subscriptionsDetailsInfoPrefix = 'Подписка'; + static const subscriptionsDetailsAccessDescription = + 'Полный доступ к персональной системе тренировок'; + static const subscriptionsDetailsBuyButton = 'Оформить подписку'; + static const subscriptionsDetailsAdvantagesTitle = 'Наши преимущества'; + static const subscriptionsDetailsAdvantagesDescriptionPrefix = 'Не просто доступ к функциям, а '; + static const subscriptionsDetailsAdvantagesDescriptionHighlighted = 'индивидуальный'; + static const subscriptionsDetailsAdvantagesDescriptionSuffix = + ' фитнес-маршрут, который строится на ваших уникальных данных и целях. Получите максимум от каждой тренировки.'; + static const subscriptionsDetailsAdvantageLoadTitle = 'Умное управление нагрузкой'; + static const subscriptionsDetailsAdvantageLoadSubtitle = + 'Технология, которая помогает повысить эффективность силовых упражнений и ускорить восстановление мышц'; + static const subscriptionsDetailsAdvantageInjuryTitle = 'Профилактика травм'; + static const subscriptionsDetailsAdvantageInjurySubtitle = + 'Рекомендации по разминке и заминке, подобранные под Ваш тип тренировок'; + static const subscriptionsDetailsAdvantageDiagnosticsTitle = 'Расширенная диагностика'; + static const subscriptionsDetailsAdvantageDiagnosticsSubtitle = + 'Набор из специализированных тестов (сила, выносливость, мобильность, тип телосложения) для точного определения Вашего уровня'; + static const subscriptionsDetailsAdvantagePlanTitle = 'Персональный план'; + static const subscriptionsDetailsAdvantagePlanSubtitle = + 'Автоматически сформированная программа тренировок, которая адаптируется по мере Вашего прогресса'; + static const subscriptionsPaymentCardNumberLabel = 'Номер карты'; + static const subscriptionsPaymentCardNumberHint = '#### #### #### ####'; + static const subscriptionsPaymentCardNumberRequired = 'Введите номер карты'; + static const subscriptionsPaymentCardNumberInvalid = 'Номер карты должен состоять из 16 цифр'; + static const subscriptionsPaymentCardHolderLabel = 'Держатель карты'; + static const subscriptionsPaymentCardHolderHint = 'IVAN IVANOV'; + static const subscriptionsPaymentCardHolderRequired = 'Введите имя держателя карты'; + static const subscriptionsPaymentCardHolderInvalid = + 'Имя держателя карты должно содержать только заглавные латинские буквы и пробелы'; + static const subscriptionsPaymentExpiryLabel = 'Срок действия'; + static const subscriptionsPaymentExpiryMonthHint = 'Месяц'; + static const subscriptionsPaymentExpiryYearHint = 'Год'; + static const subscriptionsPaymentYearLabel = 'Год'; + static const subscriptionsPaymentCvvLabel = 'CVV'; + static const subscriptionsPaymentCvvHint = '***'; + static const subscriptionsPaymentRememberData = 'Запомнить мои данные'; + static const subscriptionsPaymentPayButton = 'Оплатить'; + static const subscriptionsPaymentPreviewExpiryLabel = 'Истекает'; + static const subscriptionsPaymentPreviewExpiryMonthLabel = 'MM'; + static const subscriptionsPaymentPreviewExpiryYearLabel = 'YY'; + // Workout details. static const workoutDetailsTitle = 'Тренировка'; static const workoutDetailsStartWarmupButton = 'Начать разминку'; @@ -282,6 +336,7 @@ abstract final class AppStrings { static const profileBottomLogoutTitle = 'Вы уверены, что хотите выйти?'; static const profileBottomDeleteTitle = 'Вы уверены, что хотите удалить профиль?'; static const profileBottomDeleteConfirm = 'Удалить'; + static const profileSubscriptionsButton = 'Выбрать подписку'; static const profileStatsTitle = 'Статистика тренировок пользователя'; static const profileStatsHistoryButton = 'История'; static const profileStatsVolumeMode = 'Объём'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index fe5eee90..2651db0e 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -26,6 +26,10 @@ import '../../features/profile/data/repositories/profile_statistics_repository_i import '../../features/profile/domain/repositories/profile_parameters_repository.dart'; import '../../features/profile/domain/repositories/profile_repository.dart'; import '../../features/profile/domain/repositories/profile_statistics_repository.dart'; +import '../../features/subscriptions/data/remote/subscriptions_api_client.dart'; +import '../../features/subscriptions/data/remote/subscription_payment_api_client.dart'; +import '../../features/subscriptions/data/repositories/subscriptions_repository_impl.dart'; +import '../../features/subscriptions/domain/repositories/subscriptions_repository.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'; @@ -147,6 +151,17 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton(() => SubscriptionsApiClient(di())); + di.registerLazySingleton( + () => SubscriptionPaymentApiClient(di()), + ); + di.registerLazySingleton( + () => SubscriptionsRepositoryImpl( + di(), + di(), + di(), + ), + ); // Fitness Start di.registerLazySingleton(() => FitnessStartApiClient(di())); diff --git a/lib/core/failures/feature/subscriptions/subscriptions_failure.dart b/lib/core/failures/feature/subscriptions/subscriptions_failure.dart new file mode 100644 index 00000000..b37ef64e --- /dev/null +++ b/lib/core/failures/feature/subscriptions/subscriptions_failure.dart @@ -0,0 +1,47 @@ +import '../../../constants/app_strings.dart'; +import '../../app_failure.dart'; + +/// Subscriptions application error. +sealed class SubscriptionsFailure extends AppFailure { + /// Creates an instance of [SubscriptionsFailure]. + const SubscriptionsFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Subscriptions validation failed because the provided input is invalid. +final class SubscriptionsValidationFailure extends SubscriptionsFailure { + /// Creates an instance of [SubscriptionsValidationFailure]. + const SubscriptionsValidationFailure({ + String message = AppStrings.subscriptionsValidationFailed, + super.parentException, + super.stackTrace, + }) : super(message); +} + +/// Subscriptions request failed because of infrastructure or network conditions. +final class SubscriptionsRequestFailure extends SubscriptionsFailure { + /// Creates an instance of [SubscriptionsRequestFailure]. + const SubscriptionsRequestFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Subscription could not be found in the active catalog payload. +final class SubscriptionsNotFoundFailure extends SubscriptionsFailure { + /// Creates an instance of [SubscriptionsNotFoundFailure]. + const SubscriptionsNotFoundFailure() : super(AppStrings.subscriptionsNotFound); +} + +/// Unknown subscriptions failure. +final class UnknownSubscriptionsFailure extends SubscriptionsFailure { + /// Creates an instance of [UnknownSubscriptionsFailure]. + const UnknownSubscriptionsFailure({ + super.parentException, + super.stackTrace, + }) : super(AppStrings.subscriptionsUnknown); +} diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index d801bcc3..e6a9343d 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -104,6 +104,12 @@ abstract class ApiPaths { /// The endpoint for the current user workouts overview. static const String workouts = '${apiPrefix}workouts'; + /// The endpoint for the subscriptions catalog. + static const String subscriptions = '${apiPrefix}subscriptions'; + + /// The endpoint for paying for a subscription. + static const String paymentSubscription = '${apiPrefix}payment/subscription'; + /// The endpoint for starting a workout. static const String workoutsStart = '$workouts/start'; diff --git a/lib/core/network/mappers/image_url_mapper.dart b/lib/core/network/mappers/image_url_mapper.dart new file mode 100644 index 00000000..a9dbd645 --- /dev/null +++ b/lib/core/network/mappers/image_url_mapper.dart @@ -0,0 +1,16 @@ +import '../api_paths.dart'; + +/// Normalizes relative backend image paths into absolute URLs. +String normalizeBackendImageUrl(String rawImage) { + final image = rawImage.trim(); + if (image.isEmpty) return ''; + if (image.startsWith('http://') || image.startsWith('https://')) { + return image; + } + + final normalizedPath = image.replaceFirst(RegExp(r'^/+'), ''); + final storagePath = normalizedPath.startsWith('storage/') + ? normalizedPath + : 'storage/$normalizedPath'; + return Uri.parse(ApiPaths.baseUrl).resolve(storagePath).toString(); +} diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index fbf7cc71..7ef0f2d5 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -23,6 +23,9 @@ import '../../features/offline/presentation/pages/offline_page.dart'; import '../../features/profile/presentation/pages/profile_page_builder.dart'; import '../../features/root/presentation/pages/root_screen.dart'; import '../../features/splash/presentation/pages/splash_page.dart'; +import '../../features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart'; +import '../../features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart'; +import '../../features/subscriptions/domain/entities/subscription_catalog_item.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'; @@ -271,6 +274,27 @@ final router = GoRouter( path: AppRoutePaths.offlinePath, builder: (context, state) => const OfflinePage(), ), + GoRoute( + path: AppRoutePaths.subscriptionsCatalogPath, + builder: (_, _) => const SubscriptionsCatalogPageBuilder(), + routes: [ + GoRoute( + path: 'details/:subscriptionId', + redirect: (_, state) { + final rawSubscriptionId = state.pathParameters['subscriptionId']; + final subscriptionId = int.tryParse(rawSubscriptionId ?? ''); + if (subscriptionId == null || subscriptionId <= 0) { + return AppRoutePaths.subscriptionsCatalogPath; + } + return null; + }, + builder: (_, state) => SubscriptionsDetailsPageBuilder( + subscriptionId: int.parse(state.pathParameters['subscriptionId']!), + seedItem: state.extra is SubscriptionCatalogItem ? state.extra as SubscriptionCatalogItem : null, + ), + ), + ], + ), GoRoute( path: AppRoutePaths.signInPath, builder: (_, _) => const SignInPageBuilder(), diff --git a/lib/core/router/router_paths.dart b/lib/core/router/router_paths.dart index 8d2ada72..1cace498 100644 --- a/lib/core/router/router_paths.dart +++ b/lib/core/router/router_paths.dart @@ -67,6 +67,19 @@ abstract class AppRoutePaths { /// Route path for workouts overview. static const workoutsPath = '/workouts'; + /// Route path for subscriptions catalog. + static const subscriptionsCatalogPath = '/subscriptions'; + + /// Base route path for a concrete subscriptions details screen. + static const subscriptionsDetailsBasePath = '$subscriptionsCatalogPath/details'; + + /// Route path pattern for a concrete subscription details screen. + static const subscriptionsDetailsPath = '$subscriptionsDetailsBasePath/:subscriptionId'; + + /// Builds the concrete route path for subscription details by [subscriptionId]. + static String subscriptionsDetailsConcretePath(int subscriptionId) => + '$subscriptionsDetailsBasePath/$subscriptionId'; + /// Base route path for a concrete workout details screen. static const workoutDetailsBasePath = '$workoutsPath/details'; diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index aa50a5b2..e682dd15 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -119,7 +119,12 @@ class ProfilePage extends StatelessWidget { onPressed: () => _openHistoryDialog(context), child: const Text(AppStrings.profileStatsHistoryButton), ), - const SizedBox(height: 36), + const SizedBox(height: 24), + MainButton( + onPressed: () => context.push(AppRoutePaths.subscriptionsCatalogPath), + child: const Text(AppStrings.profileSubscriptionsButton), + ), + const SizedBox(height: 24), const CurrentPhaseSectionWidget(), const SizedBox(height: 36), const ProfileParametersSectionWidget(), diff --git a/lib/features/subscriptions/data/dto/subscription_catalog_item_dto.dart b/lib/features/subscriptions/data/dto/subscription_catalog_item_dto.dart new file mode 100644 index 00000000..49b554f3 --- /dev/null +++ b/lib/features/subscriptions/data/dto/subscription_catalog_item_dto.dart @@ -0,0 +1,45 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'subscription_catalog_item_dto.g.dart'; + +/// DTO for a subscriptions catalog item. +@JsonSerializable(createToJson: false) +class SubscriptionCatalogItemDto { + /// Subscription identifier. + final int id; + + /// Subscription backend name. + final String name; + + /// Subscription description. + final String description; + + /// Subscription image path or URL. + final String image; + + /// Subscription price. + final String price; + + /// Subscription duration in days. + @JsonKey(name: 'duration_days') + final int durationDays; + + /// Whether the subscription is active in the catalog. + @JsonKey(name: 'is_active') + final bool isActive; + + /// Creates an instance of [SubscriptionCatalogItemDto]. + SubscriptionCatalogItemDto({ + required this.id, + required this.name, + required this.description, + required this.image, + required this.price, + required this.durationDays, + required this.isActive, + }); + + /// Creates a [SubscriptionCatalogItemDto] from JSON. + factory SubscriptionCatalogItemDto.fromJson(Map json) => + _$SubscriptionCatalogItemDtoFromJson(json); +} diff --git a/lib/features/subscriptions/data/dto/subscription_payment_request_dto.dart b/lib/features/subscriptions/data/dto/subscription_payment_request_dto.dart new file mode 100644 index 00000000..835d591b --- /dev/null +++ b/lib/features/subscriptions/data/dto/subscription_payment_request_dto.dart @@ -0,0 +1,53 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'subscription_payment_request_dto.g.dart'; + +/// DTO for submitting a subscription payment with manual card details. +@JsonSerializable(createFactory: false) +class SubscriptionPaymentRequestDto { + /// Subscription identifier. + @JsonKey(name: 'subscription_id') + final int subscriptionId; + + /// Whether the card should be saved. + @JsonKey(name: 'save_card') + final bool saveCard; + + /// Whether a saved card should be used. + @JsonKey(name: 'use_saved_card') + final bool useSavedCard; + + /// Manual card number. + @JsonKey(name: 'card_number') + final String cardNumber; + + /// Manual card holder name. + @JsonKey(name: 'card_holder') + final String cardHolder; + + /// Card expiry month. + @JsonKey(name: 'expiry_month') + final String expiryMonth; + + /// Card expiry year. + @JsonKey(name: 'expiry_year') + final String expiryYear; + + /// Card CVV. + final String cvv; + + /// Creates an instance of [SubscriptionPaymentRequestDto]. + SubscriptionPaymentRequestDto({ + required this.subscriptionId, + required this.saveCard, + required this.useSavedCard, + required this.cardNumber, + required this.cardHolder, + required this.expiryMonth, + required this.expiryYear, + required this.cvv, + }); + + /// Converts [SubscriptionPaymentRequestDto] to JSON. + Map toJson() => _$SubscriptionPaymentRequestDtoToJson(this); +} diff --git a/lib/features/subscriptions/data/dto/subscription_response_dto.dart b/lib/features/subscriptions/data/dto/subscription_response_dto.dart new file mode 100644 index 00000000..93b80fcb --- /dev/null +++ b/lib/features/subscriptions/data/dto/subscription_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'subscription_catalog_item_dto.dart'; + +part 'subscription_response_dto.g.dart'; + +/// DTO for a single subscription response envelope. +@JsonSerializable(createToJson: false) +class SubscriptionResponseDto { + /// Subscription payload. + final SubscriptionCatalogItemDto data; + + /// Creates an instance of [SubscriptionResponseDto]. + SubscriptionResponseDto({required this.data}); + + /// Creates a [SubscriptionResponseDto] from JSON. + factory SubscriptionResponseDto.fromJson(Map json) => + _$SubscriptionResponseDtoFromJson(json); +} diff --git a/lib/features/subscriptions/data/dto/subscriptions_response_dto.dart b/lib/features/subscriptions/data/dto/subscriptions_response_dto.dart new file mode 100644 index 00000000..1151aef1 --- /dev/null +++ b/lib/features/subscriptions/data/dto/subscriptions_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'subscription_catalog_item_dto.dart'; + +part 'subscriptions_response_dto.g.dart'; + +/// DTO for subscriptions response envelope. +@JsonSerializable(createToJson: false) +class SubscriptionsResponseDto { + /// Subscriptions payload. + final List data; + + /// Creates an instance of [SubscriptionsResponseDto]. + SubscriptionsResponseDto({required this.data}); + + /// Creates a [SubscriptionsResponseDto] from JSON. + factory SubscriptionsResponseDto.fromJson(Map json) => + _$SubscriptionsResponseDtoFromJson(json); +} diff --git a/lib/features/subscriptions/data/mappers/subscription_catalog_mapper.dart b/lib/features/subscriptions/data/mappers/subscription_catalog_mapper.dart new file mode 100644 index 00000000..5a98f6f1 --- /dev/null +++ b/lib/features/subscriptions/data/mappers/subscription_catalog_mapper.dart @@ -0,0 +1,15 @@ +import '../../domain/entities/subscription_catalog_item.dart'; +import '../dto/subscription_catalog_item_dto.dart'; +import 'subscription_image_url_mapper.dart'; + +/// Extension that maps [SubscriptionCatalogItemDto] to [SubscriptionCatalogItem]. +extension SubscriptionCatalogItemMapper on SubscriptionCatalogItemDto { + /// Converts DTO to a domain entity. + SubscriptionCatalogItem toEntity() => SubscriptionCatalogItem( + id: id, + name: name, + description: description, + price: price, + imageUrl: normalizeSubscriptionImageUrl(image), + ); +} diff --git a/lib/features/subscriptions/data/mappers/subscription_image_url_mapper.dart b/lib/features/subscriptions/data/mappers/subscription_image_url_mapper.dart new file mode 100644 index 00000000..eaf7640b --- /dev/null +++ b/lib/features/subscriptions/data/mappers/subscription_image_url_mapper.dart @@ -0,0 +1,4 @@ +import '../../../../core/network/mappers/image_url_mapper.dart'; + +/// Normalizes relative backend subscription image paths into absolute URLs. +String normalizeSubscriptionImageUrl(String rawImage) => normalizeBackendImageUrl(rawImage); diff --git a/lib/features/subscriptions/data/mappers/subscriptions_failure_mapper.dart b/lib/features/subscriptions/data/mappers/subscriptions_failure_mapper.dart new file mode 100644 index 00000000..747b4f7b --- /dev/null +++ b/lib/features/subscriptions/data/mappers/subscriptions_failure_mapper.dart @@ -0,0 +1,62 @@ +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/failures/helpers/validation_message_builder.dart'; +import '../../../../core/failures/network/network_failure.dart'; + +/// Extension to map [NetworkFailure] into [SubscriptionsFailure]. +extension SubscriptionsFailureMapper on NetworkFailure { + /// Maps a [NetworkFailure] into a subscriptions-specific failure. + SubscriptionsFailure toSubscriptionsFailure() { + if (this case ValidationFailure(:final errors)) { + final validationMessage = buildValidationMessage( + errors, + fallbackMessage: const SubscriptionsValidationFailure().message, + ); + return SubscriptionsValidationFailure( + message: validationMessage, + parentException: parentException, + stackTrace: stackTrace, + ); + } + + return switch (this) { + ValidationFailure() => SubscriptionsValidationFailure( + parentException: parentException, + stackTrace: stackTrace, + ), + NotFoundFailure() || + NoNetworkFailure() || + ConnectionTimeoutFailure() || + BadRequestFailure() || + UnauthorizedFailure() || + ForbiddenFailure() || + ConflictFailure() || + RateLimitedFailure() || + ServerErrorFailure() || + UnknownNetworkFailure() => SubscriptionsRequestFailure( + message, + parentException: parentException, + stackTrace: stackTrace, + ), + }; + } + + /// Maps a payment [NetworkFailure] into a subscriptions-specific failure without + /// preserving the original transport exception. + SubscriptionsFailure toSanitizedPaymentFailure() { + if (this case ValidationFailure(:final errors, :final stackTrace)) { + final validationMessage = buildValidationMessage( + errors, + fallbackMessage: const SubscriptionsValidationFailure().message, + ); + return SubscriptionsValidationFailure( + message: validationMessage, + stackTrace: stackTrace, + ); + } + + return SubscriptionsRequestFailure( + message, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/features/subscriptions/data/remote/subscription_payment_api_client.dart b/lib/features/subscriptions/data/remote/subscription_payment_api_client.dart new file mode 100644 index 00000000..754c0908 --- /dev/null +++ b/lib/features/subscriptions/data/remote/subscription_payment_api_client.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/subscription_payment_request_dto.dart'; + +part 'subscription_payment_api_client.g.dart'; + +/// Retrofit API client for subscription payment commands. +@RestApi() +abstract class SubscriptionPaymentApiClient { + /// Creates an instance of [SubscriptionPaymentApiClient]. + factory SubscriptionPaymentApiClient(Dio dio, {String? baseUrl}) = _SubscriptionPaymentApiClient; + + /// Pays for a subscription using manual card details. + @POST(ApiPaths.paymentSubscription) + Future paySubscription(@Body() SubscriptionPaymentRequestDto request); +} diff --git a/lib/features/subscriptions/data/remote/subscriptions_api_client.dart b/lib/features/subscriptions/data/remote/subscriptions_api_client.dart new file mode 100644 index 00000000..4c9c7866 --- /dev/null +++ b/lib/features/subscriptions/data/remote/subscriptions_api_client.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/subscription_response_dto.dart'; +import '../dto/subscriptions_response_dto.dart'; + +part 'subscriptions_api_client.g.dart'; + +/// Retrofit API client for subscriptions catalog requests. +@RestApi() +abstract class SubscriptionsApiClient { + /// Creates an instance of [SubscriptionsApiClient]. + factory SubscriptionsApiClient(Dio dio, {String? baseUrl}) = _SubscriptionsApiClient; + + /// Returns all subscriptions available in the catalog. + @GET(ApiPaths.subscriptions) + Future getSubscriptions(); + + /// Returns a single subscription by identifier. + @GET('${ApiPaths.subscriptions}/{subscription}') + Future getSubscriptionById(@Path('subscription') int subscriptionId); +} diff --git a/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart b/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart new file mode 100644 index 00000000..f6d88b08 --- /dev/null +++ b/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart @@ -0,0 +1,106 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/failures/network/network_failure.dart'; +import '../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/logger/app_logger.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../../domain/entities/subscription_payment_payload.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; +import '../dto/subscription_payment_request_dto.dart'; +import '../mappers/subscription_catalog_mapper.dart'; +import '../mappers/subscriptions_failure_mapper.dart'; +import '../remote/subscription_payment_api_client.dart'; +import '../remote/subscriptions_api_client.dart'; + +/// Implementation of [SubscriptionsRepository]. +final class SubscriptionsRepositoryImpl implements SubscriptionsRepository { + /// Logger for tracking subscriptions catalog operations and errors. + final AppLogger _logger; + + /// API client for subscriptions catalog requests. + final SubscriptionsApiClient _apiClient; + + /// API client for subscription payment commands. + final SubscriptionPaymentApiClient _paymentApiClient; + + /// Creates an instance of [SubscriptionsRepositoryImpl]. + SubscriptionsRepositoryImpl(this._logger, this._apiClient, this._paymentApiClient); + + @override + Future, SubscriptionsFailure>> getSubscriptions() async { + try { + final items = await _loadActiveSubscriptions(); + return Result.success(items); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toSubscriptionsFailure()); + } catch (e, s) { + _logger.e('GetSubscriptions failed with unexpected error', e, s); + return Result.failure( + UnknownSubscriptionsFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> getSubscriptionById(int id) async { + try { + final response = await _apiClient.getSubscriptionById(id); + final itemDto = response.data; + if (!itemDto.isActive) { + return const Result.failure(SubscriptionsNotFoundFailure()); + } + return Result.success(itemDto.toEntity()); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + if (networkFailure case NotFoundFailure()) { + return const Result.failure(SubscriptionsNotFoundFailure()); + } + return Result.failure(networkFailure.toSubscriptionsFailure()); + } catch (e, s) { + _logger.e('GetSubscriptionById failed with unexpected error', e, s); + return Result.failure( + UnknownSubscriptionsFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> paySubscription({ + required SubscriptionPaymentPayload payload, + }) async { + try { + await _paymentApiClient.paySubscription( + SubscriptionPaymentRequestDto( + subscriptionId: payload.subscriptionId, + saveCard: payload.saveCard, + useSavedCard: false, + cardNumber: payload.cardNumber, + cardHolder: payload.cardHolder, + expiryMonth: payload.expiryMonth, + expiryYear: payload.expiryYear, + cvv: payload.cvv, + ), + ); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toSanitizedPaymentFailure()); + } catch (e, s) { + _logger.e('PaySubscription failed with unexpected error', e, s); + return Result.failure( + UnknownSubscriptionsFailure(parentException: e, stackTrace: s), + ); + } + } + + Future> _loadActiveSubscriptions() async { + final response = await _apiClient.getSubscriptions(); + return response.data + .where((item) => item.isActive) + .map((item) => item.toEntity()) + .toList(growable: false); + } +} diff --git a/lib/features/subscriptions/domain/entities/subscription_catalog_item.dart b/lib/features/subscriptions/domain/entities/subscription_catalog_item.dart new file mode 100644 index 00000000..116561ca --- /dev/null +++ b/lib/features/subscriptions/domain/entities/subscription_catalog_item.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +/// Catalog item returned by the subscriptions endpoint. +final class SubscriptionCatalogItem extends Equatable { + /// Subscription identifier. + final int id; + + /// Subscription marketing name from catalog. + final String name; + + /// Subscription description. + final String description; + + /// Subscription price. + final String price; + + /// Normalized subscription image URL. + final String imageUrl; + + /// Creates an instance of [SubscriptionCatalogItem]. + const SubscriptionCatalogItem({ + required this.id, + required this.name, + required this.description, + required this.price, + required this.imageUrl, + }); + + @override + List get props => [ + id, + name, + description, + price, + imageUrl, + ]; +} diff --git a/lib/features/subscriptions/domain/entities/subscription_payment_payload.dart b/lib/features/subscriptions/domain/entities/subscription_payment_payload.dart new file mode 100644 index 00000000..658918b7 --- /dev/null +++ b/lib/features/subscriptions/domain/entities/subscription_payment_payload.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +/// Typed payment payload submitted from the subscription payment dialog. +final class SubscriptionPaymentPayload extends Equatable { + /// Subscription identifier. + final int subscriptionId; + + /// Whether the submitted card should be saved. + final bool saveCard; + + /// Card number without spaces. + final String cardNumber; + + /// Card holder name. + final String cardHolder; + + /// Expiry month. + final String expiryMonth; + + /// Expiry year. + final String expiryYear; + + /// Card CVV. + final String cvv; + + /// Creates an instance of [SubscriptionPaymentPayload]. + const SubscriptionPaymentPayload({ + required this.subscriptionId, + required this.saveCard, + required this.cardNumber, + required this.cardHolder, + required this.expiryMonth, + required this.expiryYear, + required this.cvv, + }); + + @override + List get props => [ + subscriptionId, + saveCard, + cardNumber, + cardHolder, + expiryMonth, + expiryYear, + cvv, + ]; +} diff --git a/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart b/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart new file mode 100644 index 00000000..aeb67b04 --- /dev/null +++ b/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart @@ -0,0 +1,18 @@ +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/subscription_catalog_item.dart'; +import '../entities/subscription_payment_payload.dart'; + +/// Repository interface for subscriptions catalog operations. +abstract interface class SubscriptionsRepository { + /// Returns all subscriptions available for the catalog screen. + Future, SubscriptionsFailure>> getSubscriptions(); + + /// Returns a single subscription by [id] using the catalog source of truth. + Future> getSubscriptionById(int id); + + /// Pays for a subscription using the provided [payload]. + Future> paySubscription({ + required SubscriptionPaymentPayload payload, + }); +} diff --git a/lib/features/subscriptions/presentation/cubits/subscription_details_cubit.dart b/lib/features/subscriptions/presentation/cubits/subscription_details_cubit.dart new file mode 100644 index 00000000..c24e700b --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscription_details_cubit.dart @@ -0,0 +1,47 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; + +part 'subscription_details_cubit.freezed.dart'; +part 'subscription_details_state.dart'; + +/// Cubit that manages loading of a single subscription details screen. +final class SubscriptionDetailsCubit extends Cubit { + final SubscriptionsRepository _repository; + + /// Creates an instance of [SubscriptionDetailsCubit]. + SubscriptionDetailsCubit(this._repository) : super(const SubscriptionDetailsState.initial()); + + /// Loads subscription details using [seedItem] when available. + Future loadInitial( + int subscriptionId, { + SubscriptionCatalogItem? seedItem, + }) async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + if (seedItem != null && seedItem.id == subscriptionId) { + emit(SubscriptionDetailsState.loaded(seedItem)); + return; + } + + emit(const SubscriptionDetailsState.inProgress()); + + final result = await _repository.getSubscriptionById(subscriptionId); + if (isClosed) return; + + switch (result) { + case Success(:final data): + emit(SubscriptionDetailsState.loaded(data)); + case Failure(:final error): + emit(SubscriptionDetailsState.failed(error)); + } + } +} diff --git a/lib/features/subscriptions/presentation/cubits/subscription_details_state.dart b/lib/features/subscriptions/presentation/cubits/subscription_details_state.dart new file mode 100644 index 00000000..d1e756de --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscription_details_state.dart @@ -0,0 +1,17 @@ +part of 'subscription_details_cubit.dart'; + +/// States for [SubscriptionDetailsCubit]. +@freezed +class SubscriptionDetailsState with _$SubscriptionDetailsState { + /// Initial idle state before subscription details loading. + const factory SubscriptionDetailsState.initial() = _Initial; + + /// State emitted while the details request is in progress. + const factory SubscriptionDetailsState.inProgress() = _InProgress; + + /// State emitted when details load successfully. + const factory SubscriptionDetailsState.loaded(SubscriptionCatalogItem item) = _Loaded; + + /// State emitted when details loading fails. + const factory SubscriptionDetailsState.failed(SubscriptionsFailure failure) = _Failed; +} diff --git a/lib/features/subscriptions/presentation/cubits/subscription_payment_cubit.dart b/lib/features/subscriptions/presentation/cubits/subscription_payment_cubit.dart new file mode 100644 index 00000000..135678c6 --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscription_payment_cubit.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/entities/subscription_payment_payload.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; + +part 'subscription_payment_cubit.freezed.dart'; +part 'subscription_payment_state.dart'; + +/// Cubit that manages the subscription payment submit flow. +final class SubscriptionPaymentCubit extends Cubit { + final SubscriptionsRepository _repository; + + /// Creates an instance of [SubscriptionPaymentCubit]. + SubscriptionPaymentCubit(this._repository) : super(const SubscriptionPaymentState.initial()); + + /// Attempts to pay for the currently selected subscription. + Future pay({ + required SubscriptionPaymentPayload payload, + }) async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const SubscriptionPaymentState.inProgress()); + + final result = await _repository.paySubscription(payload: payload); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const SubscriptionPaymentState.succeed()); + case Failure(:final error): + emit(SubscriptionPaymentState.failed(error)); + } + } +} diff --git a/lib/features/subscriptions/presentation/cubits/subscription_payment_state.dart b/lib/features/subscriptions/presentation/cubits/subscription_payment_state.dart new file mode 100644 index 00000000..8e64547c --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscription_payment_state.dart @@ -0,0 +1,17 @@ +part of 'subscription_payment_cubit.dart'; + +/// States for [SubscriptionPaymentCubit]. +@freezed +class SubscriptionPaymentState with _$SubscriptionPaymentState { + /// Initial idle state before submit. + const factory SubscriptionPaymentState.initial() = _Initial; + + /// State emitted while payment submit is in progress. + const factory SubscriptionPaymentState.inProgress() = _InProgress; + + /// State emitted when payment succeeds. + const factory SubscriptionPaymentState.succeed() = _Succeed; + + /// State emitted when payment fails. + const factory SubscriptionPaymentState.failed(SubscriptionsFailure failure) = _Failed; +} diff --git a/lib/features/subscriptions/presentation/cubits/subscriptions_cubit.dart b/lib/features/subscriptions/presentation/cubits/subscriptions_cubit.dart new file mode 100644 index 00000000..50e565d5 --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscriptions_cubit.dart @@ -0,0 +1,40 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; + +part 'subscriptions_cubit.freezed.dart'; +part 'subscriptions_state.dart'; + +/// Cubit that manages subscriptions catalog loading flow and emits [SubscriptionsState]. +final class SubscriptionsCubit extends Cubit { + /// Repository used for subscriptions catalog requests. + final SubscriptionsRepository _repository; + + /// Creates an instance of [SubscriptionsCubit]. + SubscriptionsCubit(this._repository) : super(const SubscriptionsState.initial()); + + /// Loads all subscriptions available for the catalog screen. + Future loadSubscriptions() async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const SubscriptionsState.inProgress()); + + final result = await _repository.getSubscriptions(); + if (isClosed) return; + + switch (result) { + case Success(:final data): + emit(SubscriptionsState.loaded(data)); + case Failure(:final error): + emit(SubscriptionsState.failed(error)); + } + } +} diff --git a/lib/features/subscriptions/presentation/cubits/subscriptions_state.dart b/lib/features/subscriptions/presentation/cubits/subscriptions_state.dart new file mode 100644 index 00000000..2c9bc8f0 --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/subscriptions_state.dart @@ -0,0 +1,17 @@ +part of 'subscriptions_cubit.dart'; + +/// States for [SubscriptionsCubit]. +@freezed +class SubscriptionsState with _$SubscriptionsState { + /// Initial idle state before subscriptions loading. + const factory SubscriptionsState.initial() = _Initial; + + /// State emitted while subscriptions request is in progress. + const factory SubscriptionsState.inProgress() = _InProgress; + + /// State emitted when subscriptions load successfully. + const factory SubscriptionsState.loaded(List items) = _Loaded; + + /// State emitted when subscriptions loading fails. + const factory SubscriptionsState.failed(SubscriptionsFailure failure) = _Failed; +} diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page.dart b/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page.dart new file mode 100644 index 00000000..1875ec12 --- /dev/null +++ b/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page.dart @@ -0,0 +1,161 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/app_strings.dart'; +import '../../../../core/router/router_paths.dart'; +import '../../../../uikit/buttons/app_back_button.dart'; +import '../../../../uikit/buttons/main_button.dart'; +import '../../../../uikit/images/app_decorative_figure.dart'; +import '../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../uikit/themes/text/app_text_theme.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../cubits/subscriptions_cubit.dart'; +import '../widgets/subscription_card.dart'; + +/// Fullscreen subscriptions catalog page. +class SubscriptionsCatalogPage extends StatelessWidget { + /// Creates an instance of [SubscriptionsCatalogPage]. + const SubscriptionsCatalogPage({super.key}); + + void _handleBack(BuildContext context) { + if (Navigator.canPop(context)) { + context.pop(); + return; + } + context.go(AppRoutePaths.profilePath); + } + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + return Scaffold( + appBar: AppBar( + leading: AppBackButton(onPressed: () => _handleBack(context)), + title: Text( + AppStrings.subscriptionsCatalogTitle, + style: textTheme.appBarTitle, + ), + ), + body: BlocBuilder( + builder: (context, state) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: -140, + top: 113, + child: IgnorePointer( + child: ExcludeSemantics( + child: Transform.scale( + scaleY: -1, + child: Transform.rotate( + angle: -163 * (math.pi / 180), + child: const AppDecorativeFigure(tone: FigureTone.primary), + ), + ), + ), + ), + ), + Positioned( + right: -80, + bottom: 20, + child: IgnorePointer( + child: ExcludeSemantics( + child: Transform.scale( + scaleY: -1, + child: Transform.rotate( + angle: -180 * (math.pi / 180), + child: const AppDecorativeFigure(tone: FigureTone.secondary), + ), + ), + ), + ), + ), + _buildStateSection(context, state), + ], + ); + }, + ), + ); + } + + Widget _buildStateSection(BuildContext context, SubscriptionsState state) { + return state.when( + initial: () => const SizedBox.shrink(), + inProgress: _buildLoadingState, + loaded: (items) => _buildLoadedState(context, items), + failed: (_) => _buildRetryState(context), + ); + } + + Widget _buildLoadingState() { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + + Widget _buildLoadedState(BuildContext context, List items) { + if (items.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Text( + AppStrings.subscriptionsCatalogEmpty, + textAlign: TextAlign.center, + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: List.generate(items.length, (index) { + final item = items[index]; + final onPressed = item.id <= 0 + ? null + : () => context.push( + AppRoutePaths.subscriptionsDetailsConcretePath(item.id), + extra: item, + ); + return Padding( + padding: EdgeInsets.only(bottom: index == items.length - 1 ? 0 : 12), + child: SubscriptionCard( + item: item, + onPressed: onPressed, + ), + ); + }), + ), + ); + } + + Widget _buildRetryState(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.subscriptionsCatalogLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 24), + MainButton( + onPressed: context.read().loadSubscriptions, + child: const Text(AppStrings.retryButton), + ), + ], + ), + ); + } +} diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart b/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart new file mode 100644 index 00000000..145fc882 --- /dev/null +++ b/lib/features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/di/di.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; +import '../cubits/subscriptions_cubit.dart'; +import 'subscriptions_catalog_page.dart'; + +/// Builder for the subscriptions catalog page. +class SubscriptionsCatalogPageBuilder extends StatelessWidget { + /// Creates an instance of [SubscriptionsCatalogPageBuilder]. + const SubscriptionsCatalogPageBuilder({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SubscriptionsCubit( + di(), + )..loadSubscriptions(), + child: const SubscriptionsCatalogPage(), + ); + } +} diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart new file mode 100644 index 00000000..6224dc61 --- /dev/null +++ b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.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/main_button.dart'; +import '../../../../uikit/cards/app_card.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 '../../domain/entities/subscription_catalog_item.dart'; +import '../cubits/subscription_details_cubit.dart'; +import '../cubits/subscription_payment_cubit.dart'; +import '../widgets/subscription_card.dart'; +import '../widgets/subscription_payment_dialog.dart'; + +/// Subscription details page. +class SubscriptionsDetailsPage extends StatelessWidget { + /// Requested subscription identifier used by retry actions. + final int subscriptionId; + + /// Creates an instance of [SubscriptionsDetailsPage]. + const SubscriptionsDetailsPage({ + required this.subscriptionId, + super.key, + }); + + void _handleBack(BuildContext context) { + if (Navigator.canPop(context)) { + context.pop(); + return; + } + context.go(AppRoutePaths.subscriptionsCatalogPath); + } + + Future _openPaymentDialog( + BuildContext context, + SubscriptionCatalogItem item, + ) async { + final didPay = await showSubscriptionPaymentDialog( + context, + item: item, + paymentCubit: context.read(), + ); + if (!context.mounted || didPay != true) return; + context.go(AppRoutePaths.profilePath); + } + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + + return Scaffold( + appBar: AppBar( + leading: AppBackButton(onPressed: () => _handleBack(context)), + title: Text( + AppStrings.subscriptionsCatalogTitle, + style: textTheme.appBarTitle, + ), + ), + body: BlocBuilder( + builder: (context, state) { + return _buildStateSection(context, state); + }, + ), + ); + } + + Widget _buildStateSection(BuildContext context, SubscriptionDetailsState state) { + return state.when( + initial: () => const SizedBox.shrink(), + inProgress: _buildLoadingState, + loaded: (item) => _buildLoadedState(context, item), + failed: (_) => _buildRetryState(context), + ); + } + + Widget _buildLoadingState() { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + + Widget _buildLoadedState(BuildContext context, SubscriptionCatalogItem item) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SubscriptionCard(item: item), + const SizedBox(height: 36), + _SubscriptionInfoCard( + item: item, + onPurchasePressed: () => _openPaymentDialog(context, item), + ), + const SizedBox(height: 36), + const _SubscriptionAdvantagesSection(), + ], + ), + ); + } + + Widget _buildRetryState(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.subscriptionsDetailsLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 24), + MainButton( + onPressed: () => context.read().loadInitial(subscriptionId), + child: const Text(AppStrings.retryButton), + ), + ], + ), + ); + } +} + +final class _SubscriptionInfoCard extends StatelessWidget { + final SubscriptionCatalogItem item; + final VoidCallback onPurchasePressed; + + const _SubscriptionInfoCard({ + required this.item, + required this.onPurchasePressed, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final descriptionItems = _splitDescription(item.description); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '${AppStrings.subscriptionsDetailsInfoPrefix} "${item.name}"', + style: textTheme.title.copyWith( + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + AppStrings.subscriptionsDetailsAccessDescription, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(descriptionItems.length, (index) { + final line = descriptionItems[index]; + return Padding( + padding: EdgeInsets.only(bottom: index == descriptionItems.length - 1 ? 0 : 8), + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.secondary.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + line, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + ), + ], + ), + ); + }), + ), + const SizedBox(height: 32), + Align( + alignment: Alignment.centerRight, + child: Text( + '${SubscriptionCard.formatPrice(item.price)} ${AppStrings.subscriptionsCatalogRubles}', + style: textTheme.bodyMedium.copyWith( + fontSize: 20, + height: 24 / 20, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + ), + const SizedBox(height: 24), + MainButton( + onPressed: onPurchasePressed, + child: const Text(AppStrings.subscriptionsDetailsBuyButton), + ), + ], + ); + } + + List _splitDescription(String description) { + final normalized = description.replaceAll('\n', ' ').replaceAll('\r', ' ').trim(); + if (normalized.isEmpty) return const []; + + final items = []; + var buffer = StringBuffer(); + + for (var index = 0; index < normalized.length; index++) { + final char = normalized[index]; + buffer.write(char); + if (char == '.' || char == '!' || char == '?') { + final sentence = buffer.toString().trim(); + if (sentence.isNotEmpty) { + items.add(sentence); + } + buffer = StringBuffer(); + } + } + + final tail = buffer.toString().trim(); + if (tail.isNotEmpty) { + items.add(tail); + } + + return items; + } +} + +final class _SubscriptionAdvantagesSection extends StatelessWidget { + const _SubscriptionAdvantagesSection(); + + @override + Widget build(BuildContext context) { + return const Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -5, + right: 0, + top: -30, + child: IgnorePointer( + child: ExcludeSemantics( + child: SvgPictureWidget.frame(AppAssets.imageLineVariant), + ), + ), + ), + _SubscriptionAdvantagesCard(), + ], + ); + } +} + +final class _SubscriptionAdvantagesCard extends StatelessWidget { + const _SubscriptionAdvantagesCard(); + + static const _items = [ + ( + AppStrings.subscriptionsDetailsAdvantageLoadTitle, + AppStrings.subscriptionsDetailsAdvantageLoadSubtitle, + ), + ( + AppStrings.subscriptionsDetailsAdvantageInjuryTitle, + AppStrings.subscriptionsDetailsAdvantageInjurySubtitle, + ), + ( + AppStrings.subscriptionsDetailsAdvantageDiagnosticsTitle, + AppStrings.subscriptionsDetailsAdvantageDiagnosticsSubtitle, + ), + ( + AppStrings.subscriptionsDetailsAdvantagePlanTitle, + AppStrings.subscriptionsDetailsAdvantagePlanSubtitle, + ), + ]; + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorTheme.secondary.withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppStrings.subscriptionsDetailsAdvantagesTitle, + style: textTheme.title.copyWith( + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: textTheme.body.copyWith(color: colorTheme.onSurface), + children: [ + const TextSpan(text: AppStrings.subscriptionsDetailsAdvantagesDescriptionPrefix), + TextSpan( + text: AppStrings.subscriptionsDetailsAdvantagesDescriptionHighlighted, + style: textTheme.body.copyWith( + color: colorTheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const TextSpan(text: AppStrings.subscriptionsDetailsAdvantagesDescriptionSuffix), + ], + ), + ), + const SizedBox(height: 24), + ...List.generate(_items.length, (index) { + final item = _items[index]; + return Padding( + padding: EdgeInsets.only(bottom: index == _items.length - 1 ? 0 : 20), + child: _SubscriptionAdvantageItemCard( + title: item.$1, + subtitle: item.$2, + ), + ); + }), + ], + ), + ), + ); + } +} + +final class _SubscriptionAdvantageItemCard extends StatelessWidget { + final String title; + final String subtitle; + + const _SubscriptionAdvantageItemCard({ + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + return AppCard( + height: 248, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPictureWidget.icon( + AppAssets.iconStats, + color: colorTheme.secondary, + ), + const SizedBox(height: 16), + Text( + title, + style: textTheme.bodyMedium.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + subtitle, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + ], + ), + ); + } +} diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart b/lib/features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart new file mode 100644 index 00000000..b8645ce1 --- /dev/null +++ b/lib/features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/di/di.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; +import '../cubits/subscription_details_cubit.dart'; +import '../cubits/subscription_payment_cubit.dart'; +import 'subscriptions_details_page.dart'; + +/// Builder for the subscription details page. +class SubscriptionsDetailsPageBuilder extends StatelessWidget { + /// Requested subscription identifier. + final int subscriptionId; + + /// Optional seeded item passed from the catalog route. + final SubscriptionCatalogItem? seedItem; + + /// Creates an instance of [SubscriptionsDetailsPageBuilder]. + const SubscriptionsDetailsPageBuilder({ + required this.subscriptionId, + this.seedItem, + super.key, + }); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => SubscriptionDetailsCubit( + di(), + )..loadInitial(subscriptionId, seedItem: seedItem), + ), + BlocProvider( + create: (_) => SubscriptionPaymentCubit(di()), + ), + ], + child: SubscriptionsDetailsPage(subscriptionId: subscriptionId), + ); + } +} diff --git a/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart b/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart new file mode 100644 index 00000000..286d6c30 --- /dev/null +++ b/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart @@ -0,0 +1,126 @@ +import '../../../../core/constants/app_strings.dart'; + +/// Shared validators for subscription payment form fields. +abstract final class SubscriptionPaymentValidators { + static const _hiddenValidationError = 'invalid'; + static const _maxFutureYears = 20; + static final _cardHolderPattern = RegExp(r'^[A-Z ]+$'); + + /// Validates a payment card number. + static String? cardNumber(String? value) { + final digits = _digitsOnly(value); + if (digits.isEmpty) { + return AppStrings.subscriptionsPaymentCardNumberRequired; + } + if (digits.length != 16) { + return AppStrings.subscriptionsPaymentCardNumberInvalid; + } + return null; + } + + /// Validates a payment card holder. + static String? cardHolder(String? value) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentCardHolderRequired; + } + if (!_cardHolderPattern.hasMatch(trimmed)) { + return AppStrings.subscriptionsPaymentCardHolderInvalid; + } + return null; + } + + /// Validates a payment expiry month. + static String? expiryMonth( + String? value, { + String? yearValue, + DateTime? now, + }) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentExpiryMonthHint; + } + + final month = int.tryParse(trimmed); + if (month == null || month < 1 || month > 12) { + return AppStrings.subscriptionsPaymentExpiryMonthHint; + } + + final year = int.tryParse(_trimmed(yearValue)); + if (year != null) { + final currentDate = now ?? DateTime.now(); + if (_isExpired(month: month, year: year, now: currentDate) || + _isTooFarInFuture(year: year, now: currentDate)) { + return _hiddenValidationError; + } + } + return null; + } + + /// Validates a payment expiry year. + static String? expiryYear( + String? value, { + String? monthValue, + DateTime? now, + }) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + if (trimmed.length != 4) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + + final year = int.tryParse(trimmed); + if (year == null) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + + final currentDate = now ?? DateTime.now(); + if (year < currentDate.year || _isTooFarInFuture(year: year, now: currentDate)) { + return _hiddenValidationError; + } + + final month = int.tryParse(_trimmed(monthValue)); + if (month != null && + month >= 1 && + month <= 12 && + _isExpired(month: month, year: year, now: currentDate)) { + return _hiddenValidationError; + } + return null; + } + + /// Validates a payment CVV. + static String? cvv(String? value) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentCvvHint; + } + if (trimmed.length != 3 || int.tryParse(trimmed) == null) { + return AppStrings.subscriptionsPaymentCvvHint; + } + return null; + } + + static String _trimmed(String? value) => value?.trim() ?? ''; + + static String _digitsOnly(String? value) => (value ?? '').replaceAll(RegExp(r'\D'), ''); + + static bool _isExpired({ + required int month, + required int year, + required DateTime now, + }) { + if (year < now.year) return true; + if (year == now.year && month < now.month) return true; + return false; + } + + static bool _isTooFarInFuture({ + required int year, + required DateTime now, + }) { + return year > now.year + _maxFutureYears; + } +} diff --git a/lib/features/subscriptions/presentation/widgets/subscription_card.dart b/lib/features/subscriptions/presentation/widgets/subscription_card.dart new file mode 100644 index 00000000..fa0421e0 --- /dev/null +++ b/lib/features/subscriptions/presentation/widgets/subscription_card.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/constants/app_strings.dart'; +import '../../../../uikit/cards/app_card.dart'; +import '../../../../uikit/images/network_image_widget.dart'; +import '../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../uikit/themes/text/app_text_theme.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; + +/// Visual card for a single subscriptions catalog item. +class SubscriptionCard extends StatelessWidget { + /// Catalog item displayed by this card. + final SubscriptionCatalogItem item; + + /// Optional tap callback for opening the subscription details screen. + final VoidCallback? onPressed; + + /// Creates an instance of [SubscriptionCard]. + const SubscriptionCard({ + required this.item, + this.onPressed, + super.key, + }); + + static const double _cardHeight = 276; + + /// Formats a backend price string for subscriptions UI. + static String formatPrice(String value) { + final normalized = value.trim().replaceAll(',', '.'); + if (normalized.endsWith('.00')) { + return normalized.substring(0, normalized.length - 3); + } + return normalized.replaceAll('.', ','); + } + + static ({String value, String unit}) _buildPeriodParts(String name) { + final match = RegExp(r'^\s*(\d+)\s+(.+?)\s*$').firstMatch(name); + if (match != null) { + return ( + value: match.group(1)!, + unit: match.group(2)!, + ); + } + return ( + value: name.trim(), + unit: '', + ); + } + + static const List _benefits = [ + AppStrings.subscriptionsCatalogBenefitTests, + AppStrings.subscriptionsCatalogBenefitExercises, + ]; + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final period = _buildPeriodParts(item.name); + final content = SizedBox( + height: 306, + child: Stack( + clipBehavior: Clip.none, + children: [ + Align( + alignment: Alignment.bottomCenter, + child: AppCard( + height: _cardHeight, + contentPadding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '${formatPrice(item.price)} ${AppStrings.subscriptionsCatalogRubles}', + style: textTheme.bodyMedium.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + const SizedBox(height: 32), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _benefits + .map( + (benefit) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '•', + style: textTheme.body.copyWith( + color: colorTheme.onSurface, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + benefit, + style: textTheme.body.copyWith( + color: colorTheme.onSurface, + ), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ], + ), + ), + ), + Positioned( + left: 20, + top: 0, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: period.value, + style: textTheme.bodyMedium.copyWith( + fontSize: 96, + height: 0.85, + fontWeight: FontWeight.w100, + color: colorTheme.darkHint, + ), + ), + TextSpan( + text: period.unit.isEmpty ? '' : ' ${period.unit}', + style: textTheme.bodyMedium.copyWith( + fontSize: 20, + height: 30 / 20, + fontWeight: FontWeight.w300, + color: colorTheme.darkHint, + ), + ), + ], + ), + ), + ), + Positioned( + right: 0, + top: 0, + child: NetworkImageWidget( + imageUrl: item.imageUrl, + height: 230, + ), + ), + ], + ), + ); + if (onPressed == null) { + return content; + } + return Semantics( + button: true, + child: Material( + color: Colors.transparent, + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + child: content, + ), + ), + ); + } +} diff --git a/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart b/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart new file mode 100644 index 00000000..7f57a81f --- /dev/null +++ b/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/constants/app_assets.dart'; +import '../../../../core/constants/app_strings.dart'; +import '../../../../uikit/buttons/button_state.dart'; +import '../../../../uikit/buttons/main_button.dart'; +import '../../../../uikit/buttons/secondary_button.dart'; +import '../../../../uikit/dialogs/app_feedback_dialog.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 '../../../profile/presentation/widgets/profile_dialog_shell.dart'; +import '../../domain/entities/subscription_catalog_item.dart'; +import '../../domain/entities/subscription_payment_payload.dart'; +import '../cubits/subscription_payment_cubit.dart'; +import '../validators/subscription_payment_validators.dart'; +import 'subscription_card.dart'; +import 'subscription_payment_text_field.dart'; + +/// Opens the subscription payment dialog. +Future showSubscriptionPaymentDialog( + BuildContext context, { + required SubscriptionCatalogItem item, + required SubscriptionPaymentCubit paymentCubit, +}) { + return showProfileDialog( + context, + insetPadding: const EdgeInsets.symmetric(horizontal: 24), + contentPadding: EdgeInsets.zero, + child: BlocProvider.value( + value: paymentCubit, + child: SubscriptionPaymentDialog(item: item), + ), + ); +} + +/// Dialog with manual card form for paying for a subscription. +class SubscriptionPaymentDialog extends StatefulWidget { + /// Subscription currently being purchased. + final SubscriptionCatalogItem item; + + /// Creates an instance of [SubscriptionPaymentDialog]. + const SubscriptionPaymentDialog({ + required this.item, + super.key, + }); + + @override + State createState() => _SubscriptionPaymentDialogState(); +} + +class _SubscriptionPaymentDialogState extends State { + final _formKey = GlobalKey(); + final _cardNumberController = TextEditingController(); + final _previewCardNumberController = TextEditingController(); + final _cardHolderController = TextEditingController(); + final _expiryMonthController = TextEditingController(); + final _expiryYearController = TextEditingController(); + final _cvvController = TextEditingController(); + + bool _rememberData = false; + + @override + void initState() { + super.initState(); + _cardNumberController.addListener(_syncPreviewCardNumber); + _cardNumberController.addListener(_handlePreviewChanged); + _cardHolderController.addListener(_handlePreviewChanged); + _expiryMonthController.addListener(_handlePreviewChanged); + _expiryYearController.addListener(_handlePreviewChanged); + } + + @override + void dispose() { + _cardNumberController + ..removeListener(_syncPreviewCardNumber) + ..removeListener(_handlePreviewChanged) + ..dispose(); + _cardHolderController.removeListener(_handlePreviewChanged); + _previewCardNumberController.dispose(); + _cardHolderController.dispose(); + _expiryMonthController.removeListener(_handlePreviewChanged); + _expiryMonthController.dispose(); + _expiryYearController.removeListener(_handlePreviewChanged); + _expiryYearController.dispose(); + _cvvController.dispose(); + super.dispose(); + } + + void _handlePreviewChanged() { + if (!mounted) return; + setState(() {}); + } + + void _syncPreviewCardNumber() { + final digits = _cardNumberController.text.replaceAll(RegExp(r'\D'), ''); + final chunks = []; + for (var index = 0; index < digits.length; index += 4) { + final end = (index + 4).clamp(0, digits.length); + chunks.add(digits.substring(index, end)); + } + _previewCardNumberController.text = chunks.join(' '); + } + + void _submit() { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + context.read().pay( + payload: SubscriptionPaymentPayload( + subscriptionId: widget.item.id, + saveCard: _rememberData, + cardNumber: _cardNumberController.text.replaceAll(RegExp(r'\D'), ''), + cardHolder: _cardHolderController.text.trim(), + expiryMonth: _expiryMonthController.text.trim(), + expiryYear: _expiryYearController.text.trim(), + cvv: _cvvController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + state.whenOrNull( + succeed: () => Navigator.of(context).pop(true), + failed: (failure) { + if (failure.message.isEmpty) return; + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ); + }, + ); + }, + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + final colorTheme = AppColorTheme.of(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(28, 83, 28, 40), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + SubscriptionPaymentTextField( + controller: _cardNumberController, + labelText: AppStrings.subscriptionsPaymentCardNumberLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCardNumberHint, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [const _CardNumberTextInputFormatter()], + validator: SubscriptionPaymentValidators.cardNumber, + ), + const SizedBox(height: 12), + SubscriptionPaymentTextField( + controller: _cardHolderController, + labelText: AppStrings.subscriptionsPaymentCardHolderLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCardHolderHint, + enabled: !isInProgress, + keyboardType: TextInputType.name, + textInputAction: TextInputAction.next, + validator: SubscriptionPaymentValidators.cardHolder, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: Text( + AppStrings.subscriptionsPaymentExpiryLabel, + style: AppTextTheme.of(context).label.copyWith( + color: colorTheme.hint, + ), + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: SubscriptionPaymentTextField( + controller: _expiryMonthController, + labelText: AppStrings.subscriptionsPaymentExpiryLabel, + semanticsLabel: AppStrings.subscriptionsPaymentExpiryLabel, + hintText: AppStrings.subscriptionsPaymentExpiryMonthHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + showLabel: false, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + validator: (value) => + SubscriptionPaymentValidators.expiryMonth( + value, + yearValue: _expiryYearController.text, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SubscriptionPaymentTextField( + controller: _expiryYearController, + labelText: AppStrings.subscriptionsPaymentYearLabel, + semanticsLabel: AppStrings.subscriptionsPaymentYearLabel, + hintText: AppStrings.subscriptionsPaymentExpiryYearHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + showLabel: false, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + validator: (value) => + SubscriptionPaymentValidators.expiryYear( + value, + monthValue: _expiryMonthController.text, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: SubscriptionPaymentTextField( + controller: _cvvController, + labelText: AppStrings.subscriptionsPaymentCvvLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCvvHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + obscureText: true, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(3), + ], + validator: SubscriptionPaymentValidators.cvv, + ), + ), + ], + ), + const SizedBox(height: 8), + _RememberDataRow( + value: _rememberData, + enabled: !isInProgress, + onChanged: () => setState(() => _rememberData = !_rememberData), + ), + const SizedBox(height: 36), + MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: _submit, + child: Text( + '${AppStrings.subscriptionsPaymentPayButton} ' + '${SubscriptionCard.formatPrice(widget.item.price)}₽', + ), + ), + const SizedBox(height: 8), + SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: () => Navigator.of(context).pop(false), + child: const Text(AppStrings.profileCancelButton), + ), + ], + ), + ), + ), + Positioned( + top: -100, + left: 0, + right: 0, + child: _PaymentPreviewCard( + previewCardNumber: _previewCardNumberController.text, + cardHolder: _cardHolderController.text, + expiryMonth: _expiryMonthController.text.trim(), + expiryYear: _expiryYearController.text.trim(), + ), + ), + ], + ); + }, + ); + } +} + +final class _PaymentPreviewCard extends StatelessWidget { + final String previewCardNumber; + final String cardHolder; + final String expiryMonth; + final String expiryYear; + + const _PaymentPreviewCard({ + required this.previewCardNumber, + required this.cardHolder, + required this.expiryMonth, + required this.expiryYear, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + + return SizedBox( + child: Stack( + children: [ + const Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: SvgPictureWidget.icon(AppAssets.iconCardBig), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 52, 24, 12), + child: Column( + children: [ + SizedBox( + width: 224, + child: _PaymentPreviewNumberField( + value: previewCardNumber, + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppStrings.subscriptionsPaymentCardHolderLabel, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + const SizedBox(height: 4), + Text( + cardHolder.isEmpty + ? AppStrings.subscriptionsPaymentCardHolderHint + : cardHolder, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + AppStrings.subscriptionsPaymentPreviewExpiryLabel, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + const SizedBox(height: 4), + Text( + [expiryMonth, expiryYear] + .where((value) => value.isNotEmpty) + .join('/') + .ifEmpty( + '${AppStrings.subscriptionsPaymentPreviewExpiryMonthLabel}/' + '${AppStrings.subscriptionsPaymentPreviewExpiryYearLabel}', + ), + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +final class _PaymentPreviewNumberField extends StatelessWidget { + final String value; + + const _PaymentPreviewNumberField({ + required this.value, + }); + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final displayText = value.isEmpty ? AppStrings.subscriptionsPaymentCardNumberHint : value; + final textColor = value.isEmpty ? colorTheme.outline : colorTheme.onSurface; + + return Semantics( + label: AppStrings.subscriptionsPaymentCardNumberLabel, + textField: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Text( + displayText, + style: textTheme.body.copyWith(color: textColor), + ), + ), + ), + ); + } +} + +final class _RememberDataRow extends StatelessWidget { + final bool value; + final bool enabled; + final VoidCallback onChanged; + + const _RememberDataRow({ + required this.value, + required this.enabled, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + return Row( + children: [ + Semantics( + label: AppStrings.subscriptionsPaymentRememberData, + checked: value, + enabled: enabled, + onTap: enabled ? onChanged : null, + child: InkWell( + onTap: enabled ? onChanged : null, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 16, + height: 16, + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: value ? colorTheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: value ? colorTheme.primary : colorTheme.darkHint, + ), + ), + child: value + ? Icon( + Icons.check_rounded, + size: 12, + color: colorTheme.onPrimary, + ) + : null, + ), + ), + ), + const SizedBox(width: 4), + Text( + AppStrings.subscriptionsPaymentRememberData, + style: textTheme.bodySmall.copyWith( + fontSize: 10, + height: 15 / 10, + fontWeight: FontWeight.w400, + color: colorTheme.onSurface, + ), + ), + ], + ); + } +} + +extension on String { + String ifEmpty(String fallback) => isEmpty ? fallback : this; +} + +String _formatCardNumber(String value) { + final digits = value.replaceAll(RegExp(r'\D'), ''); + final limitedDigits = digits.length > 16 ? digits.substring(0, 16) : digits; + final chunks = []; + + for (var index = 0; index < limitedDigits.length; index += 4) { + final end = (index + 4).clamp(0, limitedDigits.length); + chunks.add(limitedDigits.substring(index, end)); + } + + return chunks.join(' '); +} + +final class _CardNumberTextInputFormatter extends TextInputFormatter { + const _CardNumberTextInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final formatted = _formatCardNumber(newValue.text); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } +} diff --git a/lib/features/subscriptions/presentation/widgets/subscription_payment_text_field.dart b/lib/features/subscriptions/presentation/widgets/subscription_payment_text_field.dart new file mode 100644 index 00000000..00d26ed1 --- /dev/null +++ b/lib/features/subscriptions/presentation/widgets/subscription_payment_text_field.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../uikit/themes/text/app_text_theme.dart'; + +/// Payment-specific text field used only inside the subscriptions checkout flow. +class SubscriptionPaymentTextField extends StatelessWidget { + /// Text controller. + final TextEditingController controller; + + /// Whether the field is enabled. + final bool enabled; + + /// Field label shown above the input. + final String labelText; + + /// Optional label color override. + final Color? labelColor; + + /// Placeholder text. + final String? hintText; + + /// Optional semantics label when the visible label is hidden. + final String? semanticsLabel; + + /// Keyboard configuration. + final TextInputType keyboardType; + + /// Keyboard action button. + final TextInputAction textInputAction; + + /// Optional validator. + final String? Function(String?)? validator; + + /// Optional submit callback. + final ValueChanged? onFieldSubmitted; + + /// Optional input formatters. + final List? inputFormatters; + + /// Whether to hide text. + final bool obscureText; + + /// Whether the visible label should be rendered. + final bool showLabel; + + /// Whether validation error text should be shown. + final bool showErrorText; + + /// Creates an instance of [SubscriptionPaymentTextField]. + const SubscriptionPaymentTextField({ + required this.controller, + required this.enabled, + required this.labelText, + required this.keyboardType, + required this.textInputAction, + this.labelColor, + this.hintText, + this.semanticsLabel, + this.validator, + this.onFieldSubmitted, + this.inputFormatters, + this.obscureText = false, + this.showLabel = true, + this.showErrorText = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLabel) ...[ + ExcludeSemantics( + child: Text( + labelText, + style: textTheme.label.copyWith(color: labelColor ?? colorTheme.onSurface), + ), + ), + const SizedBox(height: 4), + ], + Semantics( + label: semanticsLabel ?? labelText, + textField: true, + child: TextFormField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + obscureText: obscureText, + textInputAction: textInputAction, + onFieldSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + cursorColor: colorTheme.primary, + decoration: InputDecoration( + hintText: hintText, + errorStyle: showErrorText + ? null + : const TextStyle( + fontSize: 0, + height: 0, + color: Colors.transparent, + ), + ), + validator: validator, + ), + ), + ], + ); + } +} diff --git a/lib/features/workouts/data/mappers/workout_image_url_mapper.dart b/lib/features/workouts/data/mappers/workout_image_url_mapper.dart index ad3b1396..af54a04b 100644 --- a/lib/features/workouts/data/mappers/workout_image_url_mapper.dart +++ b/lib/features/workouts/data/mappers/workout_image_url_mapper.dart @@ -1,16 +1,4 @@ -import '../../../../core/network/api_paths.dart'; +import '../../../../core/network/mappers/image_url_mapper.dart'; /// Normalizes relative backend workout image paths into absolute URLs. -String normalizeWorkoutImageUrl(String rawImage) { - final image = rawImage.trim(); - if (image.isEmpty) return ''; - if (image.startsWith('http://') || image.startsWith('https://')) { - return image; - } - - final normalizedPath = image.replaceFirst(RegExp(r'^/+'), ''); - final storagePath = normalizedPath.startsWith('storage/') - ? normalizedPath - : 'storage/$normalizedPath'; - return Uri.parse(ApiPaths.baseUrl).resolve(storagePath).toString(); -} +String normalizeWorkoutImageUrl(String rawImage) => normalizeBackendImageUrl(rawImage); diff --git a/lib/uikit/cards/app_card.dart b/lib/uikit/cards/app_card.dart index bbae7b00..68ffbdee 100644 --- a/lib/uikit/cards/app_card.dart +++ b/lib/uikit/cards/app_card.dart @@ -11,10 +11,14 @@ class AppCard extends StatelessWidget { /// Internal content padding. final EdgeInsetsGeometry contentPadding; + /// Optional card height. + final double? height; + /// Creates an instance of [AppCard]. const AppCard({ required this.child, this.contentPadding = const EdgeInsets.symmetric(horizontal: 20, vertical: 18), + this.height, super.key, }); @@ -37,9 +41,12 @@ class AppCard extends StatelessWidget { color: colorTheme.surface, borderRadius: BorderRadius.circular(12), ), - child: Padding( - padding: contentPadding, - child: child, + child: SizedBox( + height: height, + child: Padding( + padding: contentPadding, + child: child, + ), ), ), ); diff --git a/lib/uikit/inputs/app_input_field.dart b/lib/uikit/inputs/app_input_field.dart index c3946230..08c66a60 100644 --- a/lib/uikit/inputs/app_input_field.dart +++ b/lib/uikit/inputs/app_input_field.dart @@ -12,6 +12,9 @@ class AppInputField extends StatefulWidget { /// Field label shown above the input. final String labelText; + /// Optional semantics label when the visible label is hidden. + final String? semanticsLabel; + /// Placeholder text. final String hintText; @@ -33,6 +36,9 @@ class AppInputField extends StatefulWidget { /// Text alignment inside the input. final TextAlign textAlign; + /// Whether the visible label should be rendered. + final bool showLabel; + /// Creates an instance of [AppInputField]. const AppInputField({ required this.controller, @@ -44,6 +50,8 @@ class AppInputField extends StatefulWidget { this.textInputAction, this.inputFormatters, this.textAlign = TextAlign.center, + this.showLabel = true, + this.semanticsLabel, super.key, }); @@ -83,15 +91,17 @@ class _AppInputFieldState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ExcludeSemantics( - child: Text( - widget.labelText, - style: textTheme.label.copyWith(color: colorTheme.hint), + if (widget.showLabel) ...[ + ExcludeSemantics( + child: Text( + widget.labelText, + style: textTheme.label.copyWith(color: colorTheme.hint), + ), ), - ), - const SizedBox(height: 6), + const SizedBox(height: 6), + ], Semantics( - label: widget.labelText, + label: widget.semanticsLabel ?? widget.labelText, textField: true, child: TextFormField( controller: widget.controller, diff --git a/test/features/subscriptions/data/mappers/subscriptions_failure_mapper_test.dart b/test/features/subscriptions/data/mappers/subscriptions_failure_mapper_test.dart new file mode 100644 index 00000000..e2926ee1 --- /dev/null +++ b/test/features/subscriptions/data/mappers/subscriptions_failure_mapper_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/failures/network/network_failure.dart'; +import 'package:moveup_flutter/features/subscriptions/data/mappers/subscriptions_failure_mapper.dart'; + +void main() { + group('SubscriptionsFailureMapper.toSubscriptionsFailure', () { + test('maps validation failure to SubscriptionsValidationFailure', () { + final failure = const ValidationFailure( + errors: { + 'cvv': ['invalid_cvv'], + }, + ).toSubscriptionsFailure(); + + expect(failure, isA()); + expect(failure.message, 'invalid_cvv'); + }); + + test('maps not found to generic SubscriptionsRequestFailure', () { + final failure = const NotFoundFailure().toSubscriptionsFailure(); + + expect(failure, isA()); + expect(failure.message, const NotFoundFailure().message); + }); + }); + + group('SubscriptionsFailureMapper.toSanitizedPaymentFailure', () { + test('maps validation failure without parentException', () { + final failure = const ValidationFailure( + errors: { + 'cvv': ['invalid_cvv'], + }, + ).toSanitizedPaymentFailure(); + + expect(failure, isA()); + expect(failure.message, 'invalid_cvv'); + expect(failure.parentException, isNull); + }); + + test('maps request failure without parentException', () { + final failure = const ServerErrorFailure().toSanitizedPaymentFailure(); + + expect(failure, isA()); + expect(failure.message, const ServerErrorFailure().message); + expect(failure.parentException, isNull); + }); + }); +} diff --git a/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart b/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart new file mode 100644 index 00000000..f6ab388e --- /dev/null +++ b/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart @@ -0,0 +1,251 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/subscriptions/data/dto/subscription_payment_request_dto.dart'; +import 'package:moveup_flutter/features/subscriptions/data/remote/subscription_payment_api_client.dart'; +import 'package:moveup_flutter/features/subscriptions/data/remote/subscriptions_api_client.dart'; +import 'package:moveup_flutter/features/subscriptions/data/repositories/subscriptions_repository_impl.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; + +import '../../support/subscriptions_dto_fixtures.dart'; +import 'subscriptions_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockSubscriptionsApiClient apiClient; + late MockSubscriptionPaymentApiClient paymentApiClient; + late SubscriptionsRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockSubscriptionsApiClient(); + paymentApiClient = MockSubscriptionPaymentApiClient(); + repository = SubscriptionsRepositoryImpl(logger, apiClient, paymentApiClient); + }); + + group('SubscriptionsRepositoryImpl', () { + group('SubscriptionsRepositoryImpl.getSubscriptions', () { + test('returns success(items) when api succeeds', () async { + final responseDto = createSubscriptionsResponseDto(); + final expectedItems = createSubscriptionCatalogItems(); + when(apiClient.getSubscriptions()).thenAnswer((_) async => responseDto); + + final result = await repository.getSubscriptions(); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedItems); + expect(result.success!.first.imageUrl, expectedItems.first.imageUrl); + expect(result.success!.first.name, '1 месяц'); + + verify(apiClient.getSubscriptions()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('filters out inactive subscriptions from the catalog payload', () async { + final responseDto = createSubscriptionsResponseDto(includeInactive: true); + final expectedItems = createSubscriptionCatalogItems(); + when(apiClient.getSubscriptions()).thenAnswer((_) async => responseDto); + + final result = await repository.getSubscriptions(); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedItems); + expect(result.success!.any((item) => item.id == 3), isFalse); + + verify(apiClient.getSubscriptions()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns success(item) when requested id exists in active catalog payload', () async { + final responseDto = createSubscriptionResponseDto(); + final expectedItem = createSubscriptionCatalogItems().last; + when(apiClient.getSubscriptionById(expectedItem.id)).thenAnswer((_) async => responseDto); + + final result = await repository.getSubscriptionById(expectedItem.id); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedItem); + + verify(apiClient.getSubscriptionById(expectedItem.id)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns SubscriptionsNotFoundFailure when api returns not found', () async { + final exception = createSubscriptionsDioBadResponseException( + path: '/subscriptions/999', + statusCode: 404, + code: 'not_found', + ); + when(apiClient.getSubscriptionById(999)).thenThrow(exception); + + final result = await repository.getSubscriptionById(999); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, isNull); + + verify(apiClient.getSubscriptionById(999)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test( + 'returns SubscriptionsNotFoundFailure when requested subscription is inactive', + () async { + final responseDto = createSubscriptionResponseDto(isActive: false); + when(apiClient.getSubscriptionById(3)).thenAnswer((_) async => responseDto); + + final result = await repository.getSubscriptionById(3); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + + verify(apiClient.getSubscriptionById(3)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }, + ); + + test('returns SubscriptionsRequestFailure when api returns server error', () async { + final exception = createSubscriptionsDioBadResponseException( + path: '/subscriptions', + statusCode: 500, + ); + when(apiClient.getSubscriptions()).thenThrow(exception); + + final result = await repository.getSubscriptions(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getSubscriptions()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownSubscriptionsFailure when unexpected exception occurs', () async { + final exception = Exception('unexpected_error'); + when(apiClient.getSubscriptions()).thenThrow(exception); + + final result = await repository.getSubscriptions(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getSubscriptions()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('SubscriptionsRepositoryImpl.paySubscription', () { + test('returns success when payment api succeeds', () async { + when(paymentApiClient.paySubscription(any)).thenAnswer((_) async {}); + + final result = await repository.paySubscription( + payload: testSubscriptionPaymentPayload, + ); + + expect(result.isSuccess, isTrue); + verify( + paymentApiClient.paySubscription( + argThat( + isA() + .having((request) => request.subscriptionId, 'subscriptionId', 2) + .having((request) => request.saveCard, 'saveCard', true) + .having((request) => request.useSavedCard, 'useSavedCard', false) + .having((request) => request.cardNumber, 'cardNumber', '4111111111111111') + .having((request) => request.cardHolder, 'cardHolder', 'IVAN IVANOV') + .having((request) => request.expiryMonth, 'expiryMonth', '12') + .having((request) => request.expiryYear, 'expiryYear', '2028') + .having((request) => request.cvv, 'cvv', '123'), + ), + ), + ).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(paymentApiClient); + }); + + test('returns SubscriptionsRequestFailure when payment api returns server error', () async { + final exception = createSubscriptionsDioBadResponseException( + path: '/payment/subscription', + statusCode: 500, + ); + when(paymentApiClient.paySubscription(any)).thenThrow(exception); + + final result = await repository.paySubscription( + payload: testSubscriptionPaymentPayload, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, isNull); + + verify(paymentApiClient.paySubscription(any)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(paymentApiClient); + }); + + test( + 'returns sanitized SubscriptionsValidationFailure on payment validation error', + () async { + final exception = createSubscriptionsDioBadResponseException( + path: '/payment/subscription', + statusCode: 422, + code: 'validation_failed', + message: 'validation_failed', + errors: { + 'cvv': ['invalid_cvv'], + }, + ); + when(paymentApiClient.paySubscription(any)).thenThrow(exception); + + final result = await repository.paySubscription( + payload: testSubscriptionPaymentPayload, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.message, 'invalid_cvv'); + expect(result.failure!.parentException, isNull); + + verify(paymentApiClient.paySubscription(any)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(paymentApiClient); + }, + ); + + test( + 'returns UnknownSubscriptionsFailure when payment throws unexpected exception', + () async { + final exception = Exception('unexpected_payment_error'); + when(paymentApiClient.paySubscription(any)).thenThrow(exception); + + final result = await repository.paySubscription( + payload: testSubscriptionPaymentPayload, + ); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(paymentApiClient.paySubscription(any)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(paymentApiClient); + }, + ); + }); + }); +} diff --git a/test/features/subscriptions/presentation/cubits/subscription_details_cubit_test.dart b/test/features/subscriptions/presentation/cubits/subscription_details_cubit_test.dart new file mode 100644 index 00000000..4b610d8e --- /dev/null +++ b/test/features/subscriptions/presentation/cubits/subscription_details_cubit_test.dart @@ -0,0 +1,70 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/entities/subscription_catalog_item.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; +import 'package:moveup_flutter/features/subscriptions/presentation/cubits/subscription_details_cubit.dart'; + +import '../../support/subscriptions_dto_fixtures.dart'; +import 'subscription_details_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockSubscriptionsRepository repository; + late SubscriptionDetailsCubit cubit; + + final item = createSubscriptionCatalogItems().last; + + setUp(() { + repository = MockSubscriptionsRepository(); + cubit = SubscriptionDetailsCubit(repository); + provideDummy>( + Success(item), + ); + }); + + group('SubscriptionDetailsCubit', () { + blocTest( + 'uses seedItem without repository call', + build: () => cubit, + act: (cubit) => cubit.loadInitial(item.id, seedItem: item), + expect: () => [ + SubscriptionDetailsState.loaded(item), + ], + verify: (_) => verifyNever(repository.getSubscriptionById(any)), + ); + + blocTest( + 'loads by id when seedItem is absent', + setUp: () => when(repository.getSubscriptionById(item.id)).thenAnswer( + (_) async => Success(item), + ), + build: () => cubit, + act: (cubit) => cubit.loadInitial(item.id), + expect: () => [ + const SubscriptionDetailsState.inProgress(), + SubscriptionDetailsState.loaded(item), + ], + verify: (_) => verify(repository.getSubscriptionById(item.id)).called(1), + ); + + blocTest( + 'emits failed when subscription is missing', + setUp: () => when(repository.getSubscriptionById(999)).thenAnswer( + (_) async => const Failure( + SubscriptionsNotFoundFailure(), + ), + ), + build: () => cubit, + act: (cubit) => cubit.loadInitial(999), + expect: () => const [ + SubscriptionDetailsState.inProgress(), + SubscriptionDetailsState.failed(SubscriptionsNotFoundFailure()), + ], + verify: (_) => verify(repository.getSubscriptionById(999)).called(1), + ); + }); +} diff --git a/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart b/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart new file mode 100644 index 00000000..bcf4fbab --- /dev/null +++ b/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart @@ -0,0 +1,79 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; +import 'package:moveup_flutter/features/subscriptions/presentation/cubits/subscription_payment_cubit.dart'; + +import '../../support/subscriptions_dto_fixtures.dart'; +import 'subscription_payment_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockSubscriptionsRepository repository; + late SubscriptionPaymentCubit cubit; + + setUp(() { + repository = MockSubscriptionsRepository(); + cubit = SubscriptionPaymentCubit(repository); + provideDummy>( + const Success(null), + ); + }); + + group('SubscriptionPaymentCubit', () { + const subscriptionsFailure = SubscriptionsRequestFailure('error_message'); + + blocTest( + 'emits succeed when pay succeeds', + setUp: () => when( + repository.paySubscription(payload: testSubscriptionPaymentPayload), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.pay(payload: testSubscriptionPaymentPayload), + expect: () => const [ + SubscriptionPaymentState.inProgress(), + SubscriptionPaymentState.succeed(), + ], + verify: (_) => + verify(repository.paySubscription(payload: testSubscriptionPaymentPayload)).called(1), + ); + + blocTest( + 'emits failed when pay fails', + setUp: () => when( + repository.paySubscription(payload: testSubscriptionPaymentPayload), + ).thenAnswer( + (_) async => const Failure(subscriptionsFailure), + ), + build: () => cubit, + act: (cubit) => cubit.pay(payload: testSubscriptionPaymentPayload), + expect: () => const [ + SubscriptionPaymentState.inProgress(), + SubscriptionPaymentState.failed(subscriptionsFailure), + ], + verify: (_) => + verify(repository.paySubscription(payload: testSubscriptionPaymentPayload)).called(1), + ); + + blocTest( + 'emits inProgress only once when pay is called twice', + setUp: () => when( + repository.paySubscription(payload: testSubscriptionPaymentPayload), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.pay(payload: testSubscriptionPaymentPayload); + cubit.pay(payload: testSubscriptionPaymentPayload); + }, + expect: () => const [ + SubscriptionPaymentState.inProgress(), + SubscriptionPaymentState.succeed(), + ], + verify: (_) => + verify(repository.paySubscription(payload: testSubscriptionPaymentPayload)).called(1), + ); + }); +} diff --git a/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart b/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart new file mode 100644 index 00000000..5bcddf05 --- /dev/null +++ b/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart @@ -0,0 +1,78 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/entities/subscription_catalog_item.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; +import 'package:moveup_flutter/features/subscriptions/presentation/cubits/subscriptions_cubit.dart'; + +import '../../support/subscriptions_dto_fixtures.dart'; +import 'subscriptions_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockSubscriptionsRepository repository; + late SubscriptionsCubit subscriptionsCubit; + + final items = createSubscriptionCatalogItems(); + + setUp(() { + repository = MockSubscriptionsRepository(); + subscriptionsCubit = SubscriptionsCubit(repository); + provideDummy, SubscriptionsFailure>>( + Success, SubscriptionsFailure>(items), + ); + }); + + group('SubscriptionsCubit', () { + const subscriptionsFailure = SubscriptionsRequestFailure('error_message'); + + blocTest( + 'emits inProgress only once when loadSubscriptions is called twice', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => Success, SubscriptionsFailure>(items), + ), + build: () => subscriptionsCubit, + act: (cubit) { + cubit.loadSubscriptions(); + cubit.loadSubscriptions(); + }, + expect: () => [ + const SubscriptionsState.inProgress(), + SubscriptionsState.loaded(items), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + + blocTest( + 'emits loaded(items) when loadSubscriptions succeeds', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => Success, SubscriptionsFailure>(items), + ), + build: () => subscriptionsCubit, + act: (cubit) => cubit.loadSubscriptions(), + expect: () => [ + const SubscriptionsState.inProgress(), + SubscriptionsState.loaded(items), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + + blocTest( + 'emits failed(subscriptionsFailure) when loadSubscriptions fails', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => + const Failure, SubscriptionsFailure>(subscriptionsFailure), + ), + build: () => subscriptionsCubit, + act: (cubit) => cubit.loadSubscriptions(), + expect: () => const [ + SubscriptionsState.inProgress(), + SubscriptionsState.failed(subscriptionsFailure), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + }); +} diff --git a/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart b/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart new file mode 100644 index 00000000..f483c880 --- /dev/null +++ b/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moveup_flutter/features/subscriptions/presentation/validators/subscription_payment_validators.dart'; + +void main() { + group('SubscriptionPaymentValidators.cardNumber', () { + const requiredMessage = 'Введите номер карты'; + const invalidMessage = 'Номер карты должен состоять из 16 цифр'; + + test('returns required error when value is empty', () { + expect(SubscriptionPaymentValidators.cardNumber(null), requiredMessage); + expect(SubscriptionPaymentValidators.cardNumber(''), requiredMessage); + expect(SubscriptionPaymentValidators.cardNumber(' '), requiredMessage); + }); + + test('returns invalid error when card number length is not 16 digits', () { + expect(SubscriptionPaymentValidators.cardNumber('1234'), invalidMessage); + expect(SubscriptionPaymentValidators.cardNumber('1234 5678 9012 345'), invalidMessage); + expect( + SubscriptionPaymentValidators.cardNumber('1234 5678 9012 34567'), + invalidMessage, + ); + }); + + test('returns null when card number contains exactly 16 digits', () { + expect( + SubscriptionPaymentValidators.cardNumber('1234 5678 9012 3456'), + isNull, + ); + }); + }); + + group('SubscriptionPaymentValidators.cardHolder', () { + const requiredMessage = 'Введите имя держателя карты'; + const invalidMessage = + 'Имя держателя карты должно содержать только заглавные латинские буквы и пробелы'; + + test('returns required error when value is empty', () { + expect(SubscriptionPaymentValidators.cardHolder(null), requiredMessage); + expect(SubscriptionPaymentValidators.cardHolder(''), requiredMessage); + expect(SubscriptionPaymentValidators.cardHolder(' '), requiredMessage); + }); + + test('returns invalid error when value contains non-uppercase latin symbols', () { + expect(SubscriptionPaymentValidators.cardHolder('Ivan Ivanov'), invalidMessage); + expect(SubscriptionPaymentValidators.cardHolder('ИВАН ИВАНОВ'), invalidMessage); + expect(SubscriptionPaymentValidators.cardHolder('IVAN1 IVANOV'), invalidMessage); + expect(SubscriptionPaymentValidators.cardHolder('IVAN- IVANOV'), invalidMessage); + }); + + test('returns null when value is valid', () { + expect(SubscriptionPaymentValidators.cardHolder('IVAN IVANOV'), isNull); + expect(SubscriptionPaymentValidators.cardHolder(' IVAN IVANOV '), isNull); + expect(SubscriptionPaymentValidators.cardHolder('IVAN'), isNull); + }); + }); + + group('SubscriptionPaymentValidators.expiryMonth', () { + const invalidMessage = 'Месяц'; + final fixedNow = DateTime(2026, 4, 2); + + test('returns invalid error when value is empty', () { + expect(SubscriptionPaymentValidators.expiryMonth(null), invalidMessage); + expect(SubscriptionPaymentValidators.expiryMonth(''), invalidMessage); + expect(SubscriptionPaymentValidators.expiryMonth(' '), invalidMessage); + }); + + test('returns invalid error when month is out of range', () { + expect(SubscriptionPaymentValidators.expiryMonth('0'), invalidMessage); + expect(SubscriptionPaymentValidators.expiryMonth('13'), invalidMessage); + expect(SubscriptionPaymentValidators.expiryMonth('99'), invalidMessage); + }); + + test('returns null when month is in range', () { + expect(SubscriptionPaymentValidators.expiryMonth('1'), isNull); + expect(SubscriptionPaymentValidators.expiryMonth('12'), isNull); + }); + + test('returns expired error when month is earlier than current month in current year', () { + expect( + SubscriptionPaymentValidators.expiryMonth( + '3', + yearValue: '2026', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns null when month is current or future in current year', () { + expect( + SubscriptionPaymentValidators.expiryMonth( + '4', + yearValue: '2026', + now: fixedNow, + ), + isNull, + ); + expect( + SubscriptionPaymentValidators.expiryMonth( + '5', + yearValue: '2026', + now: fixedNow, + ), + isNull, + ); + }); + + test('returns invalid when year is unrealistically far in the future', () { + expect( + SubscriptionPaymentValidators.expiryMonth( + '5', + yearValue: '9999', + now: fixedNow, + ), + isNotNull, + ); + }); + }); + + group('SubscriptionPaymentValidators.expiryYear', () { + const invalidMessage = 'Год'; + final fixedNow = DateTime(2026, 4, 2); + + test('returns invalid error when value is empty', () { + expect(SubscriptionPaymentValidators.expiryYear(null), invalidMessage); + expect(SubscriptionPaymentValidators.expiryYear(''), invalidMessage); + expect(SubscriptionPaymentValidators.expiryYear(' '), invalidMessage); + }); + + test('returns invalid error when year length is not 4', () { + expect(SubscriptionPaymentValidators.expiryYear('24'), invalidMessage); + expect(SubscriptionPaymentValidators.expiryYear('202'), invalidMessage); + expect(SubscriptionPaymentValidators.expiryYear('20245'), invalidMessage); + }); + + test('returns null when year length is 4', () { + expect(SubscriptionPaymentValidators.expiryYear('2026'), isNull); + expect(SubscriptionPaymentValidators.expiryYear(' 2026 '), isNull); + }); + + test('returns expired error when year is before current year', () { + expect( + SubscriptionPaymentValidators.expiryYear( + '2025', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns expired error when month is already in the past for current year', () { + expect( + SubscriptionPaymentValidators.expiryYear( + '2026', + monthValue: '3', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns null when current year is paired with current or future month', () { + expect( + SubscriptionPaymentValidators.expiryYear( + '2026', + monthValue: '4', + now: fixedNow, + ), + isNull, + ); + expect( + SubscriptionPaymentValidators.expiryYear( + '2026', + monthValue: '12', + now: fixedNow, + ), + isNull, + ); + }); + + test('returns invalid when year is unrealistically far in the future', () { + expect( + SubscriptionPaymentValidators.expiryYear( + '9999', + now: fixedNow, + ), + isNotNull, + ); + }); + }); + + group('SubscriptionPaymentValidators.cvv', () { + const invalidMessage = '***'; + + test('returns invalid error when value is empty', () { + expect(SubscriptionPaymentValidators.cvv(null), invalidMessage); + expect(SubscriptionPaymentValidators.cvv(''), invalidMessage); + expect(SubscriptionPaymentValidators.cvv(' '), invalidMessage); + }); + + test('returns invalid error when cvv length is not 3', () { + expect(SubscriptionPaymentValidators.cvv('1'), invalidMessage); + expect(SubscriptionPaymentValidators.cvv('12'), invalidMessage); + expect(SubscriptionPaymentValidators.cvv('1234'), invalidMessage); + }); + + test('returns invalid error when cvv contains non-digit characters', () { + expect(SubscriptionPaymentValidators.cvv('abc'), invalidMessage); + expect(SubscriptionPaymentValidators.cvv('12a'), invalidMessage); + }); + + test('returns null when cvv length is 3', () { + expect(SubscriptionPaymentValidators.cvv('123'), isNull); + expect(SubscriptionPaymentValidators.cvv(' 123 '), isNull); + }); + }); +} diff --git a/test/features/subscriptions/support/subscriptions_dto_fixtures.dart b/test/features/subscriptions/support/subscriptions_dto_fixtures.dart new file mode 100644 index 00000000..7871a23c --- /dev/null +++ b/test/features/subscriptions/support/subscriptions_dto_fixtures.dart @@ -0,0 +1,119 @@ +import 'package:dio/dio.dart'; +import 'package:moveup_flutter/core/network/api_paths.dart'; +import 'package:moveup_flutter/features/subscriptions/data/dto/subscription_catalog_item_dto.dart'; +import 'package:moveup_flutter/features/subscriptions/data/dto/subscription_response_dto.dart'; +import 'package:moveup_flutter/features/subscriptions/data/dto/subscriptions_response_dto.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/entities/subscription_catalog_item.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/entities/subscription_payment_payload.dart'; + +/// Test fixture for subscriptions response DTO. +SubscriptionsResponseDto createSubscriptionsResponseDto({bool includeInactive = false}) { + final data = [ + SubscriptionCatalogItemDto( + id: 1, + name: '1 месяц', + description: 'Полный доступ к тренировкам на один месяц', + image: '/subscriptions/subscription.png', + price: '550.00', + durationDays: 30, + isActive: true, + ), + SubscriptionCatalogItemDto( + id: 2, + name: '3 месяца', + description: 'Полный доступ к тренировкам на три месяца', + image: 'http://localhost:8000/storage/subscriptions/subscription-3.png', + price: '1400.00', + durationDays: 90, + isActive: true, + ), + ]; + + if (includeInactive) { + data.add( + SubscriptionCatalogItemDto( + id: 3, + name: '6 месяцев', + description: 'Архивный тариф', + image: '/subscriptions/subscription-archived.png', + price: '2500.00', + durationDays: 180, + isActive: false, + ), + ); + } + + return SubscriptionsResponseDto(data: data); +} + +/// Test fixture for subscriptions domain entities. +List createSubscriptionCatalogItems() => [ + SubscriptionCatalogItem( + id: 1, + name: '1 месяц', + description: 'Полный доступ к тренировкам на один месяц', + price: '550.00', + imageUrl: Uri.parse( + ApiPaths.baseUrl, + ).resolve('storage/subscriptions/subscription.png').toString(), + ), + const SubscriptionCatalogItem( + id: 2, + name: '3 месяца', + description: 'Полный доступ к тренировкам на три месяца', + price: '1400.00', + imageUrl: 'http://localhost:8000/storage/subscriptions/subscription-3.png', + ), +]; + +/// Test fixture for a single subscription response DTO. +SubscriptionResponseDto createSubscriptionResponseDto({ + int id = 2, + bool isActive = true, +}) => SubscriptionResponseDto( + data: SubscriptionCatalogItemDto( + id: id, + name: '3 месяца', + description: 'Полный доступ к тренировкам на три месяца', + image: 'http://localhost:8000/storage/subscriptions/subscription-3.png', + price: '1400.00', + durationDays: 90, + isActive: isActive, + ), +); + +/// Creates a bad-response [DioException] for subscriptions API tests. +DioException createSubscriptionsDioBadResponseException({ + required String path, + required int statusCode, + String code = 'server_error', + String message = 'error', + Map>? errors, +}) { + final requestOptions = RequestOptions(path: path); + return DioException( + requestOptions: requestOptions, + response: Response>( + requestOptions: requestOptions, + statusCode: statusCode, + data: { + 'success': false, + 'message': message, + 'code': code, + 'errors': ?errors, + }, + ), + type: DioExceptionType.badResponse, + ); +} + +/// Test fixture for a subscription payment payload. +const testSubscriptionPaymentPayload = SubscriptionPaymentPayload( + subscriptionId: 2, + saveCard: true, + cardNumber: '4111111111111111', + cardHolder: 'IVAN IVANOV', + expiryMonth: '12', + expiryYear: '2028', + cvv: '123', +); From f3e4d85db993a42f9500f16e40017e8bf8ab6231 Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:11:16 +0700 Subject: [PATCH 08/13] feat(profile): implement profile subscription section (#59) * feat(subs-domain): add cancel subscription contracts * feat(subs-data): implement cancel subscription command * test(subs-repo): add cancel subscription repository coverage * feat(profile-sub): add cancel subscription cubit and state * test(profile-sub): add cancel subscription cubit coverage * feat(profile-sub): add profile subscription cubit and state * test(profile-sub): add profile subscription cubit coverage * feat(uikit): add sectionTitle text token * feat(profile-ui): add cancel subscription dialog flow * feat(profile): add refresh cubit for updating profile page after sub cancel * docs: update CHANGELOG.md * fix(profile): delegate profile refresh callback to ProfileRefreshCubit * fix(subs-ui): delete unused bloc provider * chore(sub-tests): change verify check in ProfileSubscriptionCubit test --- CHANGELOG.md | 1 + lib/core/constants/app_strings.dart | 15 + lib/core/di/di.dart | 5 + lib/core/network/api_paths.dart | 3 + lib/core/router/router.dart | 6 +- .../cubits/profile_refresh_cubit.dart | 23 + .../cubits/profile_refresh_state.dart | 10 + .../cubits/profile_subscription_cubit.dart | 143 ++++++ .../cubits/profile_subscription_state.dart | 13 + .../presentation/pages/profile_page.dart | 45 +- .../pages/profile_page_builder.dart | 15 + .../profile_subscription_section_widget.dart | 440 ++++++++++++++++++ .../data/remote/subscriptions_api_client.dart | 4 + .../subscriptions_repository_impl.dart | 16 + .../subscriptions_repository.dart | 5 +- .../cubits/cancel_subscription_cubit.dart | 38 ++ .../cubits/cancel_subscription_state.dart | 17 + .../pages/subscriptions_details_page.dart | 5 + lib/uikit/themes/text/app_text_style.dart | 7 + lib/uikit/themes/text/app_text_theme.dart | 6 + .../profile_subscription_cubit_test.dart | 157 +++++++ .../subscriptions_repository_impl_test.dart | 46 ++ .../cancel_subscription_cubit_test.dart | 69 +++ 23 files changed, 1070 insertions(+), 19 deletions(-) create mode 100644 lib/features/profile/presentation/cubits/profile_refresh_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_refresh_state.dart create mode 100644 lib/features/profile/presentation/cubits/profile_subscription_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_subscription_state.dart create mode 100644 lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart create mode 100644 lib/features/subscriptions/presentation/cubits/cancel_subscription_cubit.dart create mode 100644 lib/features/subscriptions/presentation/cubits/cancel_subscription_state.dart create mode 100644 test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart create mode 100644 test/features/subscriptions/presentation/cubits/cancel_subscription_cubit_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f97342..b8d3b659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice. - Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan. - Add profile bottom section for the authenticated `/profile` tab, including logout and delete-profile confirmation actions plus direct links to the bundled legal documents. +- Profile subscription section for the authenticated `/profile` tab, including active and empty subscription states, catalog entrypoints, profile-local subscription card hydration by `subscriptionId`, cancel-subscription confirmation flow and profile page refresh after cancellation. - Authenticated subscriptions catalog screen, including dedicated subscriptions route, catalog Cubit, card UI with normalized remote images, and a profile CTA for opening available subscription plans. - Authenticated subscription details and payment flow, including a dedicated details route, catalog-backed item resolution, manual-card payment dialog, and redirect to `/profile` after successful purchase. diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index f1e94c2a..fd8a84ec 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -337,6 +337,21 @@ abstract final class AppStrings { static const profileBottomDeleteTitle = 'Вы уверены, что хотите удалить профиль?'; static const profileBottomDeleteConfirm = 'Удалить'; static const profileSubscriptionsButton = 'Выбрать подписку'; + static const profileSubscriptionActiveTitleAccent = 'Срок действия'; + static const profileSubscriptionActiveTitleSuffix = ' вашей подписки'; + static const profileSubscriptionExpiryPrefix = 'до'; + static const profileSubscriptionRenewButton = 'Продлить подписку'; + static const profileSubscriptionCancelButton = 'Отменить подписку'; + static const profileSubscriptionCancelTitle = 'Отменить подписку?'; + static const profileSubscriptionCancelDescription = 'Вы действительно хотите отменить подписку?'; + static const profileSubscriptionCancelConfirm = 'Подтвердить'; + static const profileSubscriptionEmptyTitle = 'У Вас нет активной подписки'; + static const profileSubscriptionEmptySubtitle = 'Оформите подписку и получите:'; + static const profileSubscriptionBenefitTrainings = 'Полный доступ к персональным тренировкам'; + static const profileSubscriptionBenefitTestsAndExercises = + 'Расширенный набор тестов и упражнений'; + static const profileSubscriptionCardLoadFailed = 'Не удалось загрузить подписку'; + static const profileSubscriptionCardRetryButton = 'Повторить'; static const profileStatsTitle = 'Статистика тренировок пользователя'; static const profileStatsHistoryButton = 'История'; static const profileStatsVolumeMode = 'Объём'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 2651db0e..206d6777 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -26,6 +26,7 @@ import '../../features/profile/data/repositories/profile_statistics_repository_i import '../../features/profile/domain/repositories/profile_parameters_repository.dart'; import '../../features/profile/domain/repositories/profile_repository.dart'; import '../../features/profile/domain/repositories/profile_statistics_repository.dart'; +import '../../features/profile/presentation/cubits/profile_refresh_cubit.dart'; import '../../features/subscriptions/data/remote/subscriptions_api_client.dart'; import '../../features/subscriptions/data/remote/subscription_payment_api_client.dart'; import '../../features/subscriptions/data/repositories/subscriptions_repository_impl.dart'; @@ -139,6 +140,10 @@ Future setupDI() async { di(), ), ); + di.registerLazySingleton( + () => ProfileRefreshCubit(), + dispose: (cubit) => cubit.close(), + ); di.registerLazySingleton( () => ProfileStatisticsRepositoryImpl( di(), diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index e6a9343d..c4199ee4 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -107,6 +107,9 @@ abstract class ApiPaths { /// The endpoint for the subscriptions catalog. static const String subscriptions = '${apiPrefix}subscriptions'; + /// The endpoint for cancelling the active subscription. + static const String cancelSubscription = '${apiPrefix}cancel-subscription'; + /// The endpoint for paying for a subscription. static const String paymentSubscription = '${apiPrefix}payment/subscription'; diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index 7ef0f2d5..c0330b0f 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -23,9 +23,9 @@ import '../../features/offline/presentation/pages/offline_page.dart'; import '../../features/profile/presentation/pages/profile_page_builder.dart'; import '../../features/root/presentation/pages/root_screen.dart'; import '../../features/splash/presentation/pages/splash_page.dart'; +import '../../features/subscriptions/domain/entities/subscription_catalog_item.dart'; import '../../features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart'; import '../../features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart'; -import '../../features/subscriptions/domain/entities/subscription_catalog_item.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'; @@ -290,7 +290,9 @@ final router = GoRouter( }, builder: (_, state) => SubscriptionsDetailsPageBuilder( subscriptionId: int.parse(state.pathParameters['subscriptionId']!), - seedItem: state.extra is SubscriptionCatalogItem ? state.extra as SubscriptionCatalogItem : null, + seedItem: state.extra is SubscriptionCatalogItem + ? state.extra as SubscriptionCatalogItem + : null, ), ), ], diff --git a/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart b/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart new file mode 100644 index 00000000..927a8488 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart @@ -0,0 +1,23 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_refresh_cubit.freezed.dart'; +part 'profile_refresh_state.dart'; + +/// Shared trigger for refreshing `/profile` after external flows mutate its data. +final class ProfileRefreshCubit extends Cubit { + /// Creates an instance of [ProfileRefreshCubit]. + ProfileRefreshCubit() : super(const ProfileRefreshState()); + + /// Marks the profile as needing a refresh. + void requestRefresh() { + if (state.shouldRefresh) return; + emit(const ProfileRefreshState(shouldRefresh: true)); + } + + /// Clears the pending refresh request after the UI handled it. + void consumeRefreshRequest() { + if (!state.shouldRefresh) return; + emit(const ProfileRefreshState()); + } +} diff --git a/lib/features/profile/presentation/cubits/profile_refresh_state.dart b/lib/features/profile/presentation/cubits/profile_refresh_state.dart new file mode 100644 index 00000000..1e3b6f3e --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_refresh_state.dart @@ -0,0 +1,10 @@ +part of 'profile_refresh_cubit.dart'; + +/// State for [ProfileRefreshCubit]. +@freezed +abstract class ProfileRefreshState with _$ProfileRefreshState { + /// Creates an instance of [ProfileRefreshState]. + const factory ProfileRefreshState({ + @Default(false) bool shouldRefresh, + }) = _ProfileRefreshState; +} diff --git a/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart b/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart new file mode 100644 index 00000000..cc6544e9 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart @@ -0,0 +1,143 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../../core/result/result.dart'; +import '../../../subscriptions/domain/entities/subscription_catalog_item.dart'; +import '../../../subscriptions/domain/repositories/subscriptions_repository.dart'; +import '../../domain/entities/profile_stats_history_snapshot.dart'; + +part 'profile_subscription_cubit.freezed.dart'; +part 'profile_subscription_state.dart'; + +/// Orchestrates the profile-local active subscription section state. +/// +/// `/profile` exposes the active user-subscription record, not the catalog +/// subscription id, so the card is resolved from the active catalog by +/// matching business fields like name and price. +final class ProfileSubscriptionCubit extends Cubit { + final SubscriptionsRepository _repository; + + /// Creates an instance of [ProfileSubscriptionCubit]. + ProfileSubscriptionCubit(this._repository) : super(const ProfileSubscriptionState()); + + /// Synchronizes the current profile active subscription snapshot with the section. + Future syncActiveSubscription( + ProfileActiveSubscriptionSnapshot? activeSubscription, + ) async { + final currentActiveSubscription = state.activeSubscription; + final currentSubscriptionId = currentActiveSubscription?.id; + final nextSubscriptionId = activeSubscription?.id; + + if (activeSubscription == null) { + emit(const ProfileSubscriptionState()); + return; + } + + if (currentSubscriptionId == nextSubscriptionId) { + if (currentActiveSubscription != activeSubscription) { + emit(state.copyWith(activeSubscription: activeSubscription)); + } + return; + } + + emit( + state.copyWith( + isLoading: true, + activeSubscription: activeSubscription, + item: null, + failure: null, + ), + ); + + await _loadSubscription(); + } + + /// Retries loading the active subscription card data. + Future retry() async { + final activeSubscription = state.activeSubscription; + if (activeSubscription == null || state.isLoading) return; + + emit( + state.copyWith( + isLoading: true, + failure: null, + ), + ); + + await _loadSubscription(); + } + + Future _loadSubscription() async { + final activeSubscription = state.activeSubscription; + if (activeSubscription == null) { + emit(const ProfileSubscriptionState()); + return; + } + + final result = await _repository.getSubscriptions(); + if (isClosed) return; + + switch (result) { + case Success(:final data): + final item = _findMatchingItem( + data, + activeSubscription: activeSubscription, + ); + if (item == null) { + emit( + state.copyWith( + isLoading: false, + item: null, + failure: const SubscriptionsNotFoundFailure(), + ), + ); + return; + } + emit( + state.copyWith( + isLoading: false, + item: item, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + item: null, + failure: error, + ), + ); + } + } + + SubscriptionCatalogItem? _findMatchingItem( + List items, { + required ProfileActiveSubscriptionSnapshot activeSubscription, + }) { + final normalizedName = _normalizeName(activeSubscription.name); + + for (final item in items) { + if (_normalizeName(item.name) != normalizedName) continue; + if (_pricesEqual(item.price, activeSubscription.price)) return item; + } + + for (final item in items) { + if (_normalizeName(item.name) == normalizedName) return item; + } + + return null; + } + + String _normalizeName(String value) => value.trim().toLowerCase(); + + bool _pricesEqual(String left, String right) { + final leftValue = num.tryParse(left.trim().replaceAll(',', '.')); + final rightValue = num.tryParse(right.trim().replaceAll(',', '.')); + if (leftValue != null && rightValue != null) { + return leftValue == rightValue; + } + return left.trim() == right.trim(); + } +} diff --git a/lib/features/profile/presentation/cubits/profile_subscription_state.dart b/lib/features/profile/presentation/cubits/profile_subscription_state.dart new file mode 100644 index 00000000..363421a6 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_subscription_state.dart @@ -0,0 +1,13 @@ +part of 'profile_subscription_cubit.dart'; + +/// State for [ProfileSubscriptionCubit]. +@freezed +abstract class ProfileSubscriptionState with _$ProfileSubscriptionState { + /// Creates an instance of [ProfileSubscriptionState]. + const factory ProfileSubscriptionState({ + @Default(false) bool isLoading, + ProfileActiveSubscriptionSnapshot? activeSubscription, + SubscriptionCatalogItem? item, + SubscriptionsFailure? failure, + }) = _ProfileSubscriptionState; +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index e682dd15..05e702de 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -15,6 +15,7 @@ import '../../../../../uikit/themes/text/app_text_theme.dart'; import '../../../auth/domain/entities/user.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../cubits/profile_parameters_cubit.dart'; +import '../cubits/profile_refresh_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; @@ -22,6 +23,7 @@ import '../widgets/current_phase_section_widget.dart'; import '../widgets/edit_profile_dialog.dart'; import '../widgets/profile_bottom_section_widget.dart'; import '../widgets/profile_parameters_section_widget.dart'; +import '../widgets/profile_subscription_section_widget.dart'; import '../widgets/stats/profile_history_dialog.dart'; import '../widgets/stats/stats_section_widget.dart'; import '../widgets/user_section_widget.dart'; @@ -71,18 +73,30 @@ class ProfilePage extends StatelessWidget { ), ], ), - body: BlocListener( - listenWhen: (previous, current) => - previous.historySnapshot != current.historySnapshot || - previous.parametersSnapshot != current.parametersSnapshot, - listener: (context, state) { - final historySnapshot = state.historySnapshot; - if (historySnapshot != null) { - context.read().setHistorySnapshot(historySnapshot); - } + body: MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => previous.shouldRefresh != current.shouldRefresh, + listener: (context, state) { + if (!state.shouldRefresh) return; + context.read().consumeRefreshRequest(); + unawaited(context.read().refresh()); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.historySnapshot != current.historySnapshot || + previous.parametersSnapshot != current.parametersSnapshot, + listener: (context, state) { + final historySnapshot = state.historySnapshot; + if (historySnapshot != null) { + context.read().setHistorySnapshot(historySnapshot); + } - context.read().setBootstrapSnapshot(state.parametersSnapshot); - }, + context.read().setBootstrapSnapshot(state.parametersSnapshot); + }, + ), + ], child: BlocBuilder( builder: (context, state) { final user = state.user; @@ -119,12 +133,11 @@ class ProfilePage extends StatelessWidget { onPressed: () => _openHistoryDialog(context), child: const Text(AppStrings.profileStatsHistoryButton), ), - const SizedBox(height: 24), - MainButton( - onPressed: () => context.push(AppRoutePaths.subscriptionsCatalogPath), - child: const Text(AppStrings.profileSubscriptionsButton), + const SizedBox(height: 36), + ProfileSubscriptionSectionWidget( + activeSubscription: state.historySnapshot?.activeSubscription, ), - const SizedBox(height: 24), + const SizedBox(height: 36), const CurrentPhaseSectionWidget(), const SizedBox(height: 36), const ProfileParametersSectionWidget(), diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 79873d8b..046a84d7 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -6,11 +6,15 @@ import '../../../auth/domain/entities/user.dart'; import '../../../auth/domain/repositories/auth_repository.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../../../auth/presentation/cubits/logout_cubit.dart'; +import '../../../subscriptions/domain/repositories/subscriptions_repository.dart'; +import '../../../subscriptions/presentation/cubits/cancel_subscription_cubit.dart'; import '../../domain/repositories/profile_parameters_repository.dart'; import '../../domain/repositories/profile_repository.dart'; import '../../domain/repositories/profile_statistics_repository.dart'; import '../cubits/delete_profile_cubit.dart'; import '../cubits/profile_parameters_cubit.dart'; +import '../cubits/profile_refresh_cubit.dart'; +import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import 'profile_page.dart'; @@ -46,12 +50,23 @@ class ProfilePageBuilder extends StatelessWidget { di(), )..loadInitial(), ), + BlocProvider( + create: (_) => ProfileSubscriptionCubit( + di(), + ), + ), + BlocProvider.value( + value: di(), + ), BlocProvider( create: (_) => LogoutCubit(di()), ), BlocProvider( create: (_) => DeleteProfileCubit(di()), ), + BlocProvider( + create: (_) => CancelSubscriptionCubit(di()), + ), ], child: const ProfilePage(), ); diff --git a/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart b/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart new file mode 100644 index 00000000..07a2d6b1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../core/router/router_paths.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/dialogs/app_action_dialog.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.dart'; +import '../../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../../uikit/themes/text/app_text_theme.dart'; +import '../../../subscriptions/domain/entities/subscription_catalog_item.dart'; +import '../../../subscriptions/presentation/cubits/cancel_subscription_cubit.dart'; +import '../../../subscriptions/presentation/widgets/subscription_card.dart'; +import '../../domain/entities/profile_stats_history_snapshot.dart'; +import '../cubits/profile_refresh_cubit.dart'; +import '../cubits/profile_subscription_cubit.dart'; + +/// Subscription section rendered inside `/profile`. +class ProfileSubscriptionSectionWidget extends StatefulWidget { + /// Active subscription snapshot from the canonical `/profile` payload. + final ProfileActiveSubscriptionSnapshot? activeSubscription; + + /// Creates an instance of [ProfileSubscriptionSectionWidget]. + const ProfileSubscriptionSectionWidget({ + required this.activeSubscription, + super.key, + }); + + @override + State createState() => _ProfileSubscriptionSectionWidgetState(); +} + +class _ProfileSubscriptionSectionWidgetState extends State { + bool _isCancelDialogOpen = false; + + @override + void initState() { + super.initState(); + _syncActiveSubscription(); + } + + @override + void didUpdateWidget(covariant ProfileSubscriptionSectionWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activeSubscription == widget.activeSubscription) return; + _syncActiveSubscription(); + } + + void _syncActiveSubscription() { + unawaited( + context.read().syncActiveSubscription(widget.activeSubscription), + ); + } + + void _openCatalog() { + unawaited(context.push(AppRoutePaths.subscriptionsCatalogPath)); + } + + void _showCancelDialog() { + unawaited(_openCancelDialog()); + } + + Future _openCancelDialog() async { + if (_isCancelDialogOpen) return; + _isCancelDialogOpen = true; + final cancelCubit = context.read(); + try { + await showAppActionDialog( + context, + title: AppStrings.profileSubscriptionCancelTitle, + description: AppStrings.profileSubscriptionCancelDescription, + primaryAction: BlocProvider.value( + value: cancelCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: () => context.read().cancelSubscription(), + child: const Text(AppStrings.profileSubscriptionCancelConfirm), + ); + }, + ), + ), + secondaryAction: BlocProvider.value( + value: cancelCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + return SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: _closeActiveDialog, + child: const Text(AppStrings.profileCancelButton), + ); + }, + ), + ), + ); + } finally { + _isCancelDialogOpen = false; + } + } + + void _closeActiveDialog() { + final navigator = Navigator.of(context, rootNavigator: true); + if (!navigator.canPop()) return; + + Route? topRoute; + navigator.popUntil((route) { + topRoute = route; + return true; + }); + if (topRoute is! PopupRoute) return; + + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + final activeSubscription = widget.activeSubscription; + + return BlocListener( + listener: (context, state) { + state.whenOrNull( + succeed: () { + _closeActiveDialog(); + context.read().requestRefresh(); + }, + failed: (failure) { + _closeActiveDialog(); + if (failure.message.isEmpty) return; + unawaited( + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ), + ); + }, + ); + }, + child: BlocBuilder( + builder: (context, state) { + if (activeSubscription == null) { + return _ProfileSubscriptionEmptyState( + onPressed: _openCatalog, + ); + } + + return _ProfileSubscriptionActiveState( + activeSubscription: activeSubscription, + item: state.item, + isCardLoading: state.isLoading, + hasCardFailure: state.failure != null, + onRetryPressed: () => context.read().retry(), + onRenewPressed: _openCatalog, + onCancelPressed: _showCancelDialog, + ); + }, + ), + ); + } +} + +final class _ProfileSubscriptionActiveState extends StatelessWidget { + final ProfileActiveSubscriptionSnapshot activeSubscription; + final SubscriptionCatalogItem? item; + final bool isCardLoading; + final bool hasCardFailure; + final VoidCallback onRetryPressed; + final VoidCallback onRenewPressed; + final VoidCallback onCancelPressed; + + const _ProfileSubscriptionActiveState({ + required this.activeSubscription, + required this.item, + required this.isCardLoading, + required this.hasCardFailure, + required this.onRetryPressed, + required this.onRenewPressed, + required this.onCancelPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final resolvedPrice = item?.price ?? activeSubscription.price; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RichText( + text: TextSpan( + style: textTheme.sectionTitle, + children: [ + TextSpan( + text: '*', + style: textTheme.sectionTitle.copyWith(color: colorTheme.onSurface), + ), + TextSpan( + text: AppStrings.profileSubscriptionActiveTitleAccent, + style: textTheme.sectionTitle.copyWith(color: colorTheme.secondary), + ), + TextSpan( + text: AppStrings.profileSubscriptionActiveTitleSuffix, + style: textTheme.sectionTitle.copyWith(color: colorTheme.onSurface), + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + _formatExpireDate(activeSubscription.endDate), + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 12), + if (item != null) + SubscriptionCard( + item: item!, + onPressed: () => context.push(AppRoutePaths.subscriptionsDetailsConcretePath(item!.id)), + ) + else if (isCardLoading) + const _ProfileSubscriptionCardLoadingState() + else if (hasCardFailure) + _ProfileSubscriptionCardRetryState(onRetryPressed: onRetryPressed) + else + const _ProfileSubscriptionCardLoadingState(), + const SizedBox(height: 28), + Align( + alignment: Alignment.centerRight, + child: Text( + '${SubscriptionCard.formatPrice(resolvedPrice)} ${AppStrings.subscriptionsCatalogRubles}', + style: textTheme.bodyMedium.copyWith( + fontSize: 20, + height: 24 / 20, + fontWeight: FontWeight.w600, + color: colorTheme.onSurface, + ), + ), + ), + const SizedBox(height: 24), + MainButton( + onPressed: onRenewPressed, + child: const Text(AppStrings.profileSubscriptionRenewButton), + ), + const SizedBox(height: 12), + SecondaryButton( + onPressed: onCancelPressed, + child: const Text(AppStrings.profileSubscriptionCancelButton), + ), + ], + ); + } +} + +final class _ProfileSubscriptionCardLoadingState extends StatelessWidget { + const _ProfileSubscriptionCardLoadingState(); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + + return Container( + height: 306, + alignment: Alignment.center, + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorTheme.secondary.withValues(alpha: 0.25), + ), + ), + child: const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } +} + +final class _ProfileSubscriptionCardRetryState extends StatelessWidget { + final VoidCallback onRetryPressed; + + const _ProfileSubscriptionCardRetryState({ + required this.onRetryPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorTheme.secondary.withValues(alpha: 0.25), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.profileSubscriptionCardLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.profileSubscriptionCardRetryButton), + ), + ], + ), + ); + } +} + +final class _ProfileSubscriptionEmptyState extends StatelessWidget { + final VoidCallback onPressed; + + const _ProfileSubscriptionEmptyState({ + required this.onPressed, + }); + + static const _benefits = [ + AppStrings.profileSubscriptionBenefitTrainings, + AppStrings.profileSubscriptionBenefitTestsAndExercises, + ]; + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.profileSubscriptionEmptyTitle, + style: textTheme.sectionTitle.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 8), + Text( + AppStrings.profileSubscriptionEmptySubtitle, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(_benefits.length, (index) { + return Padding( + padding: EdgeInsets.only(bottom: index == _benefits.length - 1 ? 0 : 8), + child: _ProfileSubscriptionBenefitRow(text: _benefits[index]), + ); + }), + ), + const SizedBox(height: 24), + MainButton( + onPressed: onPressed, + child: const Text(AppStrings.profileSubscriptionsButton), + ), + ], + ); + } +} + +final class _ProfileSubscriptionBenefitRow extends StatelessWidget { + final String text; + + const _ProfileSubscriptionBenefitRow({ + required this.text, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.secondary.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + ), + ), + ], + ); + } +} + +String _formatExpireDate(String rawDate) { + final parsed = DateTime.tryParse(rawDate); + if (parsed == null) { + return '${AppStrings.profileSubscriptionExpiryPrefix} $rawDate'; + } + + const months = [ + 'января', + 'февраля', + 'марта', + 'апреля', + 'мая', + 'июня', + 'июля', + 'августа', + 'сентября', + 'октября', + 'ноября', + 'декабря', + ]; + + return '${AppStrings.profileSubscriptionExpiryPrefix} ' + '${parsed.day} ${months[parsed.month - 1]} ${parsed.year} г.'; +} diff --git a/lib/features/subscriptions/data/remote/subscriptions_api_client.dart b/lib/features/subscriptions/data/remote/subscriptions_api_client.dart index 4c9c7866..00b0632a 100644 --- a/lib/features/subscriptions/data/remote/subscriptions_api_client.dart +++ b/lib/features/subscriptions/data/remote/subscriptions_api_client.dart @@ -20,4 +20,8 @@ abstract class SubscriptionsApiClient { /// Returns a single subscription by identifier. @GET('${ApiPaths.subscriptions}/{subscription}') Future getSubscriptionById(@Path('subscription') int subscriptionId); + + /// Cancels the currently active subscription. + @POST(ApiPaths.cancelSubscription) + Future cancelSubscription(); } diff --git a/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart b/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart index f6d88b08..b7132772 100644 --- a/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart +++ b/lib/features/subscriptions/data/repositories/subscriptions_repository_impl.dart @@ -96,6 +96,22 @@ final class SubscriptionsRepositoryImpl implements SubscriptionsRepository { } } + @override + Future> cancelSubscription() async { + try { + await _apiClient.cancelSubscription(); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toSubscriptionsFailure()); + } catch (e, s) { + _logger.e('CancelSubscription failed with unexpected error', e, s); + return Result.failure( + UnknownSubscriptionsFailure(parentException: e, stackTrace: s), + ); + } + } + Future> _loadActiveSubscriptions() async { final response = await _apiClient.getSubscriptions(); return response.data diff --git a/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart b/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart index aeb67b04..5e270930 100644 --- a/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart +++ b/lib/features/subscriptions/domain/repositories/subscriptions_repository.dart @@ -8,11 +8,14 @@ abstract interface class SubscriptionsRepository { /// Returns all subscriptions available for the catalog screen. Future, SubscriptionsFailure>> getSubscriptions(); - /// Returns a single subscription by [id] using the catalog source of truth. + /// Returns a single subscription by [id]. Future> getSubscriptionById(int id); /// Pays for a subscription using the provided [payload]. Future> paySubscription({ required SubscriptionPaymentPayload payload, }); + + /// Cancels the currently active subscription. + Future> cancelSubscription(); } diff --git a/lib/features/subscriptions/presentation/cubits/cancel_subscription_cubit.dart b/lib/features/subscriptions/presentation/cubits/cancel_subscription_cubit.dart new file mode 100644 index 00000000..f1ed8157 --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/cancel_subscription_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/subscriptions/subscriptions_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/repositories/subscriptions_repository.dart'; + +part 'cancel_subscription_cubit.freezed.dart'; +part 'cancel_subscription_state.dart'; + +/// Cubit that manages the active subscription cancel flow. +final class CancelSubscriptionCubit extends Cubit { + final SubscriptionsRepository _repository; + + /// Creates an instance of [CancelSubscriptionCubit]. + CancelSubscriptionCubit(this._repository) : super(const CancelSubscriptionState.initial()); + + /// Cancels the currently active subscription. + Future cancelSubscription() async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const CancelSubscriptionState.inProgress()); + + final result = await _repository.cancelSubscription(); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const CancelSubscriptionState.succeed()); + case Failure(:final error): + emit(CancelSubscriptionState.failed(error)); + } + } +} diff --git a/lib/features/subscriptions/presentation/cubits/cancel_subscription_state.dart b/lib/features/subscriptions/presentation/cubits/cancel_subscription_state.dart new file mode 100644 index 00000000..bd9a5416 --- /dev/null +++ b/lib/features/subscriptions/presentation/cubits/cancel_subscription_state.dart @@ -0,0 +1,17 @@ +part of 'cancel_subscription_cubit.dart'; + +/// State for [CancelSubscriptionCubit]. +@freezed +sealed class CancelSubscriptionState with _$CancelSubscriptionState { + /// Initial idle state. + const factory CancelSubscriptionState.initial() = _Initial; + + /// Cancel request is in progress. + const factory CancelSubscriptionState.inProgress() = _InProgress; + + /// Cancel request completed successfully. + const factory CancelSubscriptionState.succeed() = _Succeed; + + /// Cancel request failed. + const factory CancelSubscriptionState.failed(SubscriptionsFailure failure) = _Failed; +} diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart index 6224dc61..1e54225b 100644 --- a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart +++ b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.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/di/di.dart'; import '../../../../core/router/router_paths.dart'; import '../../../../uikit/buttons/app_back_button.dart'; import '../../../../uikit/buttons/main_button.dart'; @@ -11,6 +14,7 @@ import '../../../../uikit/cards/app_card.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 '../../../profile/presentation/cubits/profile_refresh_cubit.dart'; import '../../domain/entities/subscription_catalog_item.dart'; import '../cubits/subscription_details_cubit.dart'; import '../cubits/subscription_payment_cubit.dart'; @@ -46,6 +50,7 @@ class SubscriptionsDetailsPage extends StatelessWidget { paymentCubit: context.read(), ); if (!context.mounted || didPay != true) return; + di().requestRefresh(); context.go(AppRoutePaths.profilePath); } diff --git a/lib/uikit/themes/text/app_text_style.dart b/lib/uikit/themes/text/app_text_style.dart index 7edac938..687b1faf 100644 --- a/lib/uikit/themes/text/app_text_style.dart +++ b/lib/uikit/themes/text/app_text_style.dart @@ -26,6 +26,13 @@ abstract class AppTextStyle { fontWeight: FontWeight.w600, ); + static const sectionTitle = TextStyle( + fontFamily: _fontFamily, + fontSize: 18, + height: 27 / 18, + fontWeight: FontWeight.w600, + ); + static const body = TextStyle( fontFamily: _fontFamily, fontSize: 12, diff --git a/lib/uikit/themes/text/app_text_theme.dart b/lib/uikit/themes/text/app_text_theme.dart index ece8b3a1..7e14e8dd 100644 --- a/lib/uikit/themes/text/app_text_theme.dart +++ b/lib/uikit/themes/text/app_text_theme.dart @@ -13,6 +13,7 @@ class AppTextTheme extends ThemeExtension { final TextStyle display; final TextStyle title; final TextStyle appBarTitle; + final TextStyle sectionTitle; final TextStyle body; final TextStyle bodyMedium; final TextStyle bodySmall; @@ -23,6 +24,7 @@ class AppTextTheme extends ThemeExtension { required this.display, required this.title, required this.appBarTitle, + required this.sectionTitle, required this.body, required this.bodyMedium, required this.bodySmall, @@ -34,6 +36,7 @@ class AppTextTheme extends ThemeExtension { : display = AppTextStyle.display, title = AppTextStyle.title, appBarTitle = AppTextStyle.appBarTitle, + sectionTitle = AppTextStyle.sectionTitle, body = AppTextStyle.body, bodyMedium = AppTextStyle.bodyMedium, bodySmall = AppTextStyle.bodySmall, @@ -45,6 +48,7 @@ class AppTextTheme extends ThemeExtension { TextStyle? display, TextStyle? title, TextStyle? appBarTitle, + TextStyle? sectionTitle, TextStyle? body, TextStyle? bodyMedium, TextStyle? bodySmall, @@ -55,6 +59,7 @@ class AppTextTheme extends ThemeExtension { display: display ?? this.display, title: title ?? this.title, appBarTitle: appBarTitle ?? this.appBarTitle, + sectionTitle: sectionTitle ?? this.sectionTitle, body: body ?? this.body, bodyMedium: bodyMedium ?? this.bodyMedium, bodySmall: bodySmall ?? this.bodySmall, @@ -72,6 +77,7 @@ class AppTextTheme extends ThemeExtension { display: TextStyle.lerp(display, other.display, t)!, title: TextStyle.lerp(title, other.title, t)!, appBarTitle: TextStyle.lerp(appBarTitle, other.appBarTitle, t)!, + sectionTitle: TextStyle.lerp(sectionTitle, other.sectionTitle, t)!, body: TextStyle.lerp(body, other.body, t)!, bodyMedium: TextStyle.lerp(bodyMedium, other.bodyMedium, t)!, bodySmall: TextStyle.lerp(bodySmall, other.bodySmall, t)!, diff --git a/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart b/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart new file mode 100644 index 00000000..89878f14 --- /dev/null +++ b/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart @@ -0,0 +1,157 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_stats_history_snapshot.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/profile_subscription_cubit.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/entities/subscription_catalog_item.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; + +import '../../../subscriptions/support/subscriptions_dto_fixtures.dart'; +import '../../support/profile_dto_fixtures.dart'; +import 'profile_subscription_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockSubscriptionsRepository repository; + late ProfileSubscriptionCubit cubit; + + const activeSubscription = ProfileActiveSubscriptionSnapshot( + id: testProfileSubscriptionId, + name: testProfileSubscriptionName, + price: testProfileSubscriptionPrice, + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ); + final item = createSubscriptionCatalogItems().last; + + setUp(() { + repository = MockSubscriptionsRepository(); + cubit = ProfileSubscriptionCubit(repository); + provideDummy>( + Success(item), + ); + provideDummy, SubscriptionsFailure>>( + Success, SubscriptionsFailure>( + createSubscriptionCatalogItems(), + ), + ); + }); + + group('ProfileSubscriptionCubit', () { + blocTest( + 'emits empty state when active subscription is absent', + build: () => cubit, + seed: () => ProfileSubscriptionState( + activeSubscription: activeSubscription, + item: item, + failure: const SubscriptionsRequestFailure('test'), + ), + act: (cubit) => cubit.syncActiveSubscription(null), + expect: () => const [ + ProfileSubscriptionState(), + ], + verify: (_) => verifyNever(repository.getSubscriptions()), + ); + + blocTest( + 'loads details when active subscription appears', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => Success, SubscriptionsFailure>( + createSubscriptionCatalogItems(), + ), + ), + build: () => cubit, + act: (cubit) => cubit.syncActiveSubscription(activeSubscription), + expect: () => [ + const ProfileSubscriptionState( + isLoading: true, + activeSubscription: activeSubscription, + ), + ProfileSubscriptionState( + activeSubscription: activeSubscription, + item: item, + ), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + + blocTest( + 'ignores duplicate sync with same subscriptionId', + build: () => cubit, + seed: () => ProfileSubscriptionState( + activeSubscription: activeSubscription, + item: item, + ), + act: (cubit) => cubit.syncActiveSubscription(activeSubscription), + expect: () => const [], + verify: (_) => verifyNever(repository.getSubscriptions()), + ); + + blocTest( + 'emits failed retry state when details request fails', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => const Failure, SubscriptionsFailure>( + SubscriptionsRequestFailure('error_message'), + ), + ), + build: () => cubit, + act: (cubit) => cubit.syncActiveSubscription(activeSubscription), + expect: () => const [ + ProfileSubscriptionState( + isLoading: true, + activeSubscription: activeSubscription, + ), + ProfileSubscriptionState( + activeSubscription: activeSubscription, + failure: SubscriptionsRequestFailure('error_message'), + ), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + + blocTest( + 'matches active subscription to catalog item by name and price instead of active id', + setUp: () => when(repository.getSubscriptions()).thenAnswer( + (_) async => Success, SubscriptionsFailure>( + createSubscriptionCatalogItems(), + ), + ), + build: () => cubit, + act: (cubit) => cubit.syncActiveSubscription( + const ProfileActiveSubscriptionSnapshot( + id: 90, + name: '3 месяца', + price: '1400.00', + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + ), + expect: () => [ + const ProfileSubscriptionState( + isLoading: true, + activeSubscription: ProfileActiveSubscriptionSnapshot( + id: 90, + name: '3 месяца', + price: '1400.00', + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + ), + ProfileSubscriptionState( + activeSubscription: const ProfileActiveSubscriptionSnapshot( + id: 90, + name: '3 месяца', + price: '1400.00', + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + item: item, + ), + ], + verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + }); +} diff --git a/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart b/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart index f6ab388e..d1a34245 100644 --- a/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart +++ b/test/features/subscriptions/data/repositories/subscriptions_repository_impl_test.dart @@ -247,5 +247,51 @@ void main() { }, ); }); + + group('SubscriptionsRepositoryImpl.cancelSubscription', () { + test('returns success when cancel api succeeds', () async { + when(apiClient.cancelSubscription()).thenAnswer((_) async {}); + + final result = await repository.cancelSubscription(); + + expect(result.isSuccess, isTrue); + verify(apiClient.cancelSubscription()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns SubscriptionsRequestFailure when cancel api returns server error', () async { + final exception = createSubscriptionsDioBadResponseException( + path: '/cancel-subscription', + statusCode: 500, + ); + when(apiClient.cancelSubscription()).thenThrow(exception); + + final result = await repository.cancelSubscription(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.cancelSubscription()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownSubscriptionsFailure when cancel throws unexpected exception', () async { + final exception = Exception('unexpected_cancel_error'); + when(apiClient.cancelSubscription()).thenThrow(exception); + + final result = await repository.cancelSubscription(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.cancelSubscription()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); }); } diff --git a/test/features/subscriptions/presentation/cubits/cancel_subscription_cubit_test.dart b/test/features/subscriptions/presentation/cubits/cancel_subscription_cubit_test.dart new file mode 100644 index 00000000..30c3200a --- /dev/null +++ b/test/features/subscriptions/presentation/cubits/cancel_subscription_cubit_test.dart @@ -0,0 +1,69 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/subscriptions/subscriptions_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/subscriptions/domain/repositories/subscriptions_repository.dart'; +import 'package:moveup_flutter/features/subscriptions/presentation/cubits/cancel_subscription_cubit.dart'; + +import 'cancel_subscription_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockSubscriptionsRepository repository; + late CancelSubscriptionCubit cubit; + + const failure = SubscriptionsRequestFailure('test'); + + setUp(() { + repository = MockSubscriptionsRepository(); + cubit = CancelSubscriptionCubit(repository); + provideDummy>(const Success(null)); + }); + + group('CancelSubscriptionCubit', () { + blocTest( + 'emits inProgress and succeed when cancel succeeds', + setUp: () => + when(repository.cancelSubscription()).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.cancelSubscription(), + expect: () => const [ + CancelSubscriptionState.inProgress(), + CancelSubscriptionState.succeed(), + ], + verify: (_) => verify(repository.cancelSubscription()).called(1), + ); + + blocTest( + 'emits failed(failure) when cancel fails', + setUp: () => when( + repository.cancelSubscription(), + ).thenAnswer((_) async => const Failure(failure)), + build: () => cubit, + act: (cubit) => cubit.cancelSubscription(), + expect: () => const [ + CancelSubscriptionState.inProgress(), + CancelSubscriptionState.failed(failure), + ], + verify: (_) => verify(repository.cancelSubscription()).called(1), + ); + + blocTest( + 'emits inProgress only once when cancelSubscription is called twice', + setUp: () => + when(repository.cancelSubscription()).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.cancelSubscription(); + cubit.cancelSubscription(); + }, + expect: () => const [ + CancelSubscriptionState.inProgress(), + CancelSubscriptionState.succeed(), + ], + verify: (_) => verify(repository.cancelSubscription()).called(1), + ); + }); +} From 494e3ce5ff1507c56604ac430af7ade1a3117e04 Mon Sep 17 00:00:00 2001 From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:00:46 +0700 Subject: [PATCH 09/13] feat(profile): implement profile cards section (#60) * feat(cards-domain): add saved cards contracts * feat(cards-data): implement cards repository and api client * test(cards-repo): add saved cards repository coverage * feat(profile): add cards cubits for profile section * test(profile): add cards cubit coverage * feat(profile-ui): add saved cards section * test(cards): add save-card validator coverage * docs: update CHANGELOG.md * feat(profile-cards): polish ui to match the layout * fix(profile-cards): preserve the caret position when reformatting the card number * fix(profile-cards): allow normal mixed-case cardholder input * fix(cards-domain): change CardsFailureMapper checks * fix(profile-cards-ui): disable delete while a set-default request is running * fix(profile-cards-ui): add an accessible label to this icon-only delete control --- CHANGELOG.md | 1 + assets/icons/card_small.svg | 12 + assets/icons/close_variant.svg | 4 + assets/icons/plus.svg | 4 + lib/core/constants/app_assets.dart | 3 + lib/core/constants/app_strings.dart | 14 + lib/core/di/di.dart | 10 + .../failures/feature/cards/cards_failure.dart | 41 ++ lib/core/network/api_paths.dart | 9 + .../cards/data/dto/save_card_request_dto.dart | 34 ++ .../cards/data/dto/saved_card_dto.dart | 43 ++ .../data/dto/saved_cards_response_dto.dart | 19 + .../data/mappers/cards_failure_mapper.dart | 45 ++ .../cards/data/mappers/saved_card_mapper.dart | 15 + .../cards/data/remote/cards_api_client.dart | 31 ++ .../repositories/cards_repository_impl.dart | 106 ++++ .../domain/entities/save_card_payload.dart | 32 ++ .../cards/domain/entities/saved_card.dart | 42 ++ .../domain/repositories/cards_repository.dart | 21 + .../presentation/cubits/cards_cubit.dart | 51 ++ .../presentation/cubits/cards_state.dart | 12 + .../cubits/delete_card_cubit.dart | 38 ++ .../cubits/delete_card_state.dart | 17 + .../presentation/cubits/save_card_cubit.dart | 41 ++ .../presentation/cubits/save_card_state.dart | 17 + .../cubits/set_default_card_cubit.dart | 38 ++ .../cubits/set_default_card_state.dart | 17 + .../validators/card_form_validators.dart | 126 +++++ .../widgets/card_form_text_field.dart | 116 +++++ .../widgets/save_card_dialog.dart | 490 ++++++++++++++++++ .../presentation/pages/profile_page.dart | 3 + .../pages/profile_page_builder.dart | 19 + .../widgets/profile_cards_section_widget.dart | 473 +++++++++++++++++ .../cards_repository_impl_test.dart | 270 ++++++++++ .../presentation/cubits/cards_cubit_test.dart | 93 ++++ .../cubits/delete_card_cubit_test.dart | 73 +++ .../cubits/save_card_cubit_test.dart | 74 +++ .../cubits/set_default_card_cubit_test.dart | 73 +++ .../validators/card_form_validators_test.dart | 211 ++++++++ .../cards/support/cards_dto_fixtures.dart | 80 +++ 40 files changed, 2818 insertions(+) create mode 100644 assets/icons/card_small.svg create mode 100644 assets/icons/close_variant.svg create mode 100644 assets/icons/plus.svg create mode 100644 lib/core/failures/feature/cards/cards_failure.dart create mode 100644 lib/features/cards/data/dto/save_card_request_dto.dart create mode 100644 lib/features/cards/data/dto/saved_card_dto.dart create mode 100644 lib/features/cards/data/dto/saved_cards_response_dto.dart create mode 100644 lib/features/cards/data/mappers/cards_failure_mapper.dart create mode 100644 lib/features/cards/data/mappers/saved_card_mapper.dart create mode 100644 lib/features/cards/data/remote/cards_api_client.dart create mode 100644 lib/features/cards/data/repositories/cards_repository_impl.dart create mode 100644 lib/features/cards/domain/entities/save_card_payload.dart create mode 100644 lib/features/cards/domain/entities/saved_card.dart create mode 100644 lib/features/cards/domain/repositories/cards_repository.dart create mode 100644 lib/features/cards/presentation/cubits/cards_cubit.dart create mode 100644 lib/features/cards/presentation/cubits/cards_state.dart create mode 100644 lib/features/cards/presentation/cubits/delete_card_cubit.dart create mode 100644 lib/features/cards/presentation/cubits/delete_card_state.dart create mode 100644 lib/features/cards/presentation/cubits/save_card_cubit.dart create mode 100644 lib/features/cards/presentation/cubits/save_card_state.dart create mode 100644 lib/features/cards/presentation/cubits/set_default_card_cubit.dart create mode 100644 lib/features/cards/presentation/cubits/set_default_card_state.dart create mode 100644 lib/features/cards/presentation/validators/card_form_validators.dart create mode 100644 lib/features/cards/presentation/widgets/card_form_text_field.dart create mode 100644 lib/features/cards/presentation/widgets/save_card_dialog.dart create mode 100644 lib/features/profile/presentation/widgets/profile_cards_section_widget.dart create mode 100644 test/features/cards/data/repositories/cards_repository_impl_test.dart create mode 100644 test/features/cards/presentation/cubits/cards_cubit_test.dart create mode 100644 test/features/cards/presentation/cubits/delete_card_cubit_test.dart create mode 100644 test/features/cards/presentation/cubits/save_card_cubit_test.dart create mode 100644 test/features/cards/presentation/cubits/set_default_card_cubit_test.dart create mode 100644 test/features/cards/presentation/validators/card_form_validators_test.dart create mode 100644 test/features/cards/support/cards_dto_fixtures.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d3b659..daa26c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan. - Add profile bottom section for the authenticated `/profile` tab, including logout and delete-profile confirmation actions plus direct links to the bundled legal documents. - Profile subscription section for the authenticated `/profile` tab, including active and empty subscription states, catalog entrypoints, profile-local subscription card hydration by `subscriptionId`, cancel-subscription confirmation flow and profile page refresh after cancellation. +- Profile saved cards section for the authenticated `/profile` tab, including dedicated cards API/repository flow, saved-cards list rendering, add-card dialog with manual card form, default-card command, delete-card confirmation, and local refresh after successful actions. - Authenticated subscriptions catalog screen, including dedicated subscriptions route, catalog Cubit, card UI with normalized remote images, and a profile CTA for opening available subscription plans. - Authenticated subscription details and payment flow, including a dedicated details route, catalog-backed item resolution, manual-card payment dialog, and redirect to `/profile` after successful purchase. diff --git a/assets/icons/card_small.svg b/assets/icons/card_small.svg new file mode 100644 index 00000000..512161e0 --- /dev/null +++ b/assets/icons/card_small.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/close_variant.svg b/assets/icons/close_variant.svg new file mode 100644 index 00000000..177d99d8 --- /dev/null +++ b/assets/icons/close_variant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 00000000..1c8d3955 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index c4426b18..30bd2d30 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -14,6 +14,7 @@ abstract final class AppAssets { static const iconSearch = 'search'; static const iconFilter = 'filter'; static const iconClose = 'close'; + static const iconCloseVariant = 'close_variant'; static const iconNotification = 'notification'; static const iconBadFace = 'bad_face'; static const iconNormalFace = 'normal_face'; @@ -21,6 +22,8 @@ abstract final class AppAssets { static const iconArrowDown = 'arrow_down'; static const iconStats = 'stats'; static const iconCardBig = 'card_big'; + static const iconCardSmall = 'card_small'; + static const iconPlus = 'plus'; // Images. static const imageFigure = 'figure'; diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart index fd8a84ec..b40cdf2b 100644 --- a/lib/core/constants/app_strings.dart +++ b/lib/core/constants/app_strings.dart @@ -208,6 +208,20 @@ abstract final class AppStrings { 'У вас уже есть начатая тренировка. Сначала завершите её, чтобы начать новую'; static const workoutsUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + // Cards. + static const cardsValidationFailed = 'Проверьте введенные данные и попробуйте снова'; + static const cardsUnknown = 'Не удалось выполнить действие. Попробуйте снова'; + static const cardsSectionTitle = 'Мои карты'; + static const cardsLoadFailed = 'Не удалось загрузить карты'; + static const cardsAddButton = 'Добавить карту'; + static const cardsDefaultButton = 'Основная карта'; + static const cardsMakeDefaultButton = 'Сделать карту основной'; + static const cardsDeleteTitle = 'Удалить карту'; + static const cardsDeleteDescription = 'Вы действительно хотите удалить карту?'; + static const cardsDeleteConfirmButton = 'Подтвердить'; + static const cardsAddLimitTitle = 'Нельзя добавить карту'; + static const cardsAddLimitMessage = 'Можно сохранить не более 3 карт'; + // Subscriptions catalog. static const subscriptionsCatalogTitle = 'Подписки'; static const subscriptionsCatalogEmpty = 'Подписки не найдены'; diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 206d6777..a32c3f49 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -13,6 +13,9 @@ import '../../features/auth/data/remote/auth_api_client.dart'; import '../../features/auth/data/repositories/auth_repository_impl.dart'; import '../../features/auth/domain/repositories/auth_repository.dart'; import '../../features/auth/presentation/cubits/auth_session_cubit.dart'; +import '../../features/cards/data/remote/cards_api_client.dart'; +import '../../features/cards/data/repositories/cards_repository_impl.dart'; +import '../../features/cards/domain/repositories/cards_repository.dart'; 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'; @@ -157,9 +160,16 @@ Future setupDI() async { ), ); di.registerLazySingleton(() => SubscriptionsApiClient(di())); + di.registerLazySingleton(() => CardsApiClient(di())); di.registerLazySingleton( () => SubscriptionPaymentApiClient(di()), ); + di.registerLazySingleton( + () => CardsRepositoryImpl( + di(), + di(), + ), + ); di.registerLazySingleton( () => SubscriptionsRepositoryImpl( di(), diff --git a/lib/core/failures/feature/cards/cards_failure.dart b/lib/core/failures/feature/cards/cards_failure.dart new file mode 100644 index 00000000..15175f16 --- /dev/null +++ b/lib/core/failures/feature/cards/cards_failure.dart @@ -0,0 +1,41 @@ +import '../../../constants/app_strings.dart'; +import '../../app_failure.dart'; + +/// Cards application error. +sealed class CardsFailure extends AppFailure { + /// Creates an instance of [CardsFailure]. + const CardsFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Cards validation failed because the provided input is invalid. +final class CardsValidationFailure extends CardsFailure { + /// Creates an instance of [CardsValidationFailure]. + const CardsValidationFailure({ + String message = AppStrings.cardsValidationFailed, + super.parentException, + super.stackTrace, + }) : super(message); +} + +/// Cards request failed because of infrastructure or network conditions. +final class CardsRequestFailure extends CardsFailure { + /// Creates an instance of [CardsRequestFailure]. + const CardsRequestFailure( + super.message, { + super.parentException, + super.stackTrace, + }); +} + +/// Unknown cards failure. +final class UnknownCardsFailure extends CardsFailure { + /// Creates an instance of [UnknownCardsFailure]. + const UnknownCardsFailure({ + super.parentException, + super.stackTrace, + }) : super(AppStrings.cardsUnknown); +} diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index c4199ee4..c38e9903 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -113,6 +113,15 @@ abstract class ApiPaths { /// The endpoint for paying for a subscription. static const String paymentSubscription = '${apiPrefix}payment/subscription'; + /// The endpoint for saved cards list. + static const String paymentCards = '${apiPrefix}payment/cards'; + + /// The endpoint for saving a new card. + static const String paymentCardsSave = '$paymentCards/save'; + + /// The endpoint prefix for card default command. + static const String paymentCardsDefault = '$paymentCards/{cardId}/default'; + /// The endpoint for starting a workout. static const String workoutsStart = '$workouts/start'; diff --git a/lib/features/cards/data/dto/save_card_request_dto.dart b/lib/features/cards/data/dto/save_card_request_dto.dart new file mode 100644 index 00000000..f23d3a01 --- /dev/null +++ b/lib/features/cards/data/dto/save_card_request_dto.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'save_card_request_dto.g.dart'; + +/// DTO for saving a new payment card. +@JsonSerializable(createFactory: false) +class SaveCardRequestDto { + /// Manual card number. + @JsonKey(name: 'card_number') + final String cardNumber; + + /// Manual card holder name. + @JsonKey(name: 'card_holder') + final String cardHolder; + + /// Card expiry month. + @JsonKey(name: 'expiry_month') + final String expiryMonth; + + /// Card expiry year. + @JsonKey(name: 'expiry_year') + final String expiryYear; + + /// Creates an instance of [SaveCardRequestDto]. + SaveCardRequestDto({ + required this.cardNumber, + required this.cardHolder, + required this.expiryMonth, + required this.expiryYear, + }); + + /// Converts [SaveCardRequestDto] to JSON. + Map toJson() => _$SaveCardRequestDtoToJson(this); +} diff --git a/lib/features/cards/data/dto/saved_card_dto.dart b/lib/features/cards/data/dto/saved_card_dto.dart new file mode 100644 index 00000000..91edf3db --- /dev/null +++ b/lib/features/cards/data/dto/saved_card_dto.dart @@ -0,0 +1,43 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'saved_card_dto.g.dart'; + +/// DTO for a saved payment card. +@JsonSerializable(createToJson: false) +class SavedCardDto { + /// Card identifier. + final int id; + + /// Card holder name. + @JsonKey(name: 'card_holder') + final String cardHolder; + + /// Last four digits of the card number. + @JsonKey(name: 'card_last_four') + final String cardLastFour; + + /// Expiry month. + @JsonKey(name: 'expiry_month') + final String expiryMonth; + + /// Expiry year. + @JsonKey(name: 'expiry_year') + final String expiryYear; + + /// Whether the card is default. + @JsonKey(name: 'is_default') + final bool isDefault; + + /// Creates an instance of [SavedCardDto]. + SavedCardDto({ + required this.id, + required this.cardHolder, + required this.cardLastFour, + required this.expiryMonth, + required this.expiryYear, + required this.isDefault, + }); + + /// Creates a [SavedCardDto] from JSON. + factory SavedCardDto.fromJson(Map json) => _$SavedCardDtoFromJson(json); +} diff --git a/lib/features/cards/data/dto/saved_cards_response_dto.dart b/lib/features/cards/data/dto/saved_cards_response_dto.dart new file mode 100644 index 00000000..57aa9edb --- /dev/null +++ b/lib/features/cards/data/dto/saved_cards_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'saved_card_dto.dart'; + +part 'saved_cards_response_dto.g.dart'; + +/// DTO for saved cards response envelope. +@JsonSerializable(createToJson: false) +class SavedCardsResponseDto { + /// Cards payload. + final List data; + + /// Creates an instance of [SavedCardsResponseDto]. + SavedCardsResponseDto({required this.data}); + + /// Creates a [SavedCardsResponseDto] from JSON. + factory SavedCardsResponseDto.fromJson(Map json) => + _$SavedCardsResponseDtoFromJson(json); +} diff --git a/lib/features/cards/data/mappers/cards_failure_mapper.dart b/lib/features/cards/data/mappers/cards_failure_mapper.dart new file mode 100644 index 00000000..6d58f293 --- /dev/null +++ b/lib/features/cards/data/mappers/cards_failure_mapper.dart @@ -0,0 +1,45 @@ +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/failures/helpers/validation_message_builder.dart'; +import '../../../../core/failures/network/network_failure.dart'; + +/// Extension to map [NetworkFailure] into [CardsFailure]. +extension CardsFailureMapper on NetworkFailure { + /// Maps a [NetworkFailure] into a cards-specific failure. + CardsFailure toCardsFailure() { + if (this case ValidationFailure(:final errors)) { + final validationMessage = buildValidationMessage( + errors, + fallbackMessage: const CardsValidationFailure().message, + ); + return CardsValidationFailure( + message: validationMessage, + parentException: parentException, + stackTrace: stackTrace, + ); + } + return switch (this) { + ValidationFailure() => CardsValidationFailure( + parentException: parentException, + stackTrace: stackTrace, + ), + const NoNetworkFailure() || + const ConnectionTimeoutFailure() || + const BadRequestFailure() || + const UnauthorizedFailure() || + const ForbiddenFailure() || + const NotFoundFailure() || + const ConflictFailure() || + const RateLimitedFailure() || + const ServerErrorFailure() || + UnknownNetworkFailure() => CardsRequestFailure( + message, + parentException: parentException, + stackTrace: stackTrace, + ), + _ => UnknownCardsFailure( + parentException: parentException, + stackTrace: stackTrace, + ), + }; + } +} diff --git a/lib/features/cards/data/mappers/saved_card_mapper.dart b/lib/features/cards/data/mappers/saved_card_mapper.dart new file mode 100644 index 00000000..c29de2df --- /dev/null +++ b/lib/features/cards/data/mappers/saved_card_mapper.dart @@ -0,0 +1,15 @@ +import '../../domain/entities/saved_card.dart'; +import '../dto/saved_card_dto.dart'; + +/// Maps [SavedCardDto] into the cards domain layer. +extension SavedCardMapper on SavedCardDto { + /// Converts [SavedCardDto] to [SavedCard]. + SavedCard toEntity() => SavedCard( + id: id, + holderName: cardHolder, + lastFour: cardLastFour, + expiryMonth: expiryMonth, + expiryYear: expiryYear, + isDefault: isDefault, + ); +} diff --git a/lib/features/cards/data/remote/cards_api_client.dart b/lib/features/cards/data/remote/cards_api_client.dart new file mode 100644 index 00000000..b2989526 --- /dev/null +++ b/lib/features/cards/data/remote/cards_api_client.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../../../core/network/api_paths.dart'; +import '../dto/save_card_request_dto.dart'; +import '../dto/saved_cards_response_dto.dart'; + +part 'cards_api_client.g.dart'; + +/// Retrofit API client for saved cards requests and commands. +@RestApi() +abstract class CardsApiClient { + /// Creates an instance of [CardsApiClient]. + factory CardsApiClient(Dio dio, {String? baseUrl}) = _CardsApiClient; + + /// Returns all saved cards for the authenticated user. + @GET(ApiPaths.paymentCards) + Future getCards(); + + /// Saves a new card. + @POST(ApiPaths.paymentCardsSave) + Future saveCard(@Body() SaveCardRequestDto request); + + /// Marks a card as default. + @POST('${ApiPaths.paymentCards}/{cardId}/default') + Future setDefaultCard(@Path('cardId') int cardId); + + /// Deletes a saved card. + @DELETE('${ApiPaths.paymentCards}/{cardId}') + Future deleteCard(@Path('cardId') int cardId); +} diff --git a/lib/features/cards/data/repositories/cards_repository_impl.dart b/lib/features/cards/data/repositories/cards_repository_impl.dart new file mode 100644 index 00000000..908fcdf8 --- /dev/null +++ b/lib/features/cards/data/repositories/cards_repository_impl.dart @@ -0,0 +1,106 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/network/mappers/dio_exception_mapper.dart'; +import '../../../../core/result/result.dart'; +import '../../../../core/utils/logger/app_logger.dart'; +import '../../domain/entities/save_card_payload.dart'; +import '../../domain/entities/saved_card.dart'; +import '../../domain/repositories/cards_repository.dart'; +import '../dto/save_card_request_dto.dart'; +import '../mappers/cards_failure_mapper.dart'; +import '../mappers/saved_card_mapper.dart'; +import '../remote/cards_api_client.dart'; + +/// Implementation of [CardsRepository]. +final class CardsRepositoryImpl implements CardsRepository { + final AppLogger _logger; + final CardsApiClient _apiClient; + + /// Creates an instance of [CardsRepositoryImpl]. + CardsRepositoryImpl(this._logger, this._apiClient); + + @override + Future, CardsFailure>> getCards() async { + try { + final response = await _apiClient.getCards(); + final defaultCards = []; + final regularCards = []; + + for (final item in response.data.map((item) => item.toEntity())) { + if (item.isDefault) { + defaultCards.add(item); + } else { + regularCards.add(item); + } + } + + return Result.success([...defaultCards, ...regularCards]); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toCardsFailure()); + } catch (e, s) { + _logger.e('GetCards failed with unexpected error', e, s); + return Result.failure( + UnknownCardsFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> saveCard({ + required SaveCardPayload payload, + }) async { + try { + await _apiClient.saveCard( + SaveCardRequestDto( + cardNumber: payload.cardNumber, + cardHolder: payload.cardHolder, + expiryMonth: payload.expiryMonth, + expiryYear: payload.expiryYear, + ), + ); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toCardsFailure()); + } catch (e, s) { + _logger.e('SaveCard failed with unexpected error', e, s); + return Result.failure( + UnknownCardsFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> setDefaultCard(int cardId) async { + try { + await _apiClient.setDefaultCard(cardId); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toCardsFailure()); + } catch (e, s) { + _logger.e('SetDefaultCard failed with unexpected error', e, s); + return Result.failure( + UnknownCardsFailure(parentException: e, stackTrace: s), + ); + } + } + + @override + Future> deleteCard(int cardId) async { + try { + await _apiClient.deleteCard(cardId); + return const Result.success(null); + } on DioException catch (e) { + final networkFailure = e.toNetworkFailure(); + return Result.failure(networkFailure.toCardsFailure()); + } catch (e, s) { + _logger.e('DeleteCard failed with unexpected error', e, s); + return Result.failure( + UnknownCardsFailure(parentException: e, stackTrace: s), + ); + } + } +} diff --git a/lib/features/cards/domain/entities/save_card_payload.dart b/lib/features/cards/domain/entities/save_card_payload.dart new file mode 100644 index 00000000..ec4f394a --- /dev/null +++ b/lib/features/cards/domain/entities/save_card_payload.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +/// Typed payload submitted from the save-card dialog. +final class SaveCardPayload extends Equatable { + /// Card number without spaces. + final String cardNumber; + + /// Card holder name. + final String cardHolder; + + /// Expiry month. + final String expiryMonth; + + /// Expiry year. + final String expiryYear; + + /// Creates an instance of [SaveCardPayload]. + const SaveCardPayload({ + required this.cardNumber, + required this.cardHolder, + required this.expiryMonth, + required this.expiryYear, + }); + + @override + List get props => [ + cardNumber, + cardHolder, + expiryMonth, + expiryYear, + ]; +} diff --git a/lib/features/cards/domain/entities/saved_card.dart b/lib/features/cards/domain/entities/saved_card.dart new file mode 100644 index 00000000..c3c32b4f --- /dev/null +++ b/lib/features/cards/domain/entities/saved_card.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +/// Saved payment card returned by the cards endpoints. +final class SavedCard extends Equatable { + /// Card identifier. + final int id; + + /// Card holder name. + final String holderName; + + /// Last four digits of the card number. + final String lastFour; + + /// Card expiry month. + final String expiryMonth; + + /// Card expiry year. + final String expiryYear; + + /// Whether the card is the default one. + final bool isDefault; + + /// Creates an instance of [SavedCard]. + const SavedCard({ + required this.id, + required this.holderName, + required this.lastFour, + required this.expiryMonth, + required this.expiryYear, + required this.isDefault, + }); + + @override + List get props => [ + id, + holderName, + lastFour, + expiryMonth, + expiryYear, + isDefault, + ]; +} diff --git a/lib/features/cards/domain/repositories/cards_repository.dart b/lib/features/cards/domain/repositories/cards_repository.dart new file mode 100644 index 00000000..be0f2f5b --- /dev/null +++ b/lib/features/cards/domain/repositories/cards_repository.dart @@ -0,0 +1,21 @@ +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/result/result.dart'; +import '../entities/save_card_payload.dart'; +import '../entities/saved_card.dart'; + +/// Repository interface for saved cards operations. +abstract interface class CardsRepository { + /// Returns saved cards for the authenticated user. + Future, CardsFailure>> getCards(); + + /// Saves a new card using the provided [payload]. + Future> saveCard({ + required SaveCardPayload payload, + }); + + /// Marks a saved card as default. + Future> setDefaultCard(int cardId); + + /// Deletes a saved card by [cardId]. + Future> deleteCard(int cardId); +} diff --git a/lib/features/cards/presentation/cubits/cards_cubit.dart b/lib/features/cards/presentation/cubits/cards_cubit.dart new file mode 100644 index 00000000..1d6c2aa6 --- /dev/null +++ b/lib/features/cards/presentation/cubits/cards_cubit.dart @@ -0,0 +1,51 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/entities/saved_card.dart'; +import '../../domain/repositories/cards_repository.dart'; + +part 'cards_cubit.freezed.dart'; +part 'cards_state.dart'; + +/// Cubit that manages loading and refreshing saved cards. +final class CardsCubit extends Cubit { + final CardsRepository _repository; + + /// Creates an instance of [CardsCubit]. + CardsCubit(this._repository) : super(const CardsState()); + + /// Loads saved cards. + Future loadCards() async { + if (state.isLoading) return; + + emit( + state.copyWith( + isLoading: true, + failure: null, + ), + ); + + final result = await _repository.getCards(); + if (isClosed) return; + + switch (result) { + case Success(:final data): + emit( + state.copyWith( + isLoading: false, + cards: data, + failure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + failure: error, + ), + ); + } + } +} diff --git a/lib/features/cards/presentation/cubits/cards_state.dart b/lib/features/cards/presentation/cubits/cards_state.dart new file mode 100644 index 00000000..6c6ceac3 --- /dev/null +++ b/lib/features/cards/presentation/cubits/cards_state.dart @@ -0,0 +1,12 @@ +part of 'cards_cubit.dart'; + +/// State for [CardsCubit]. +@freezed +abstract class CardsState with _$CardsState { + /// Creates an instance of [CardsState]. + const factory CardsState({ + @Default(false) bool isLoading, + @Default([]) List cards, + CardsFailure? failure, + }) = _CardsState; +} diff --git a/lib/features/cards/presentation/cubits/delete_card_cubit.dart b/lib/features/cards/presentation/cubits/delete_card_cubit.dart new file mode 100644 index 00000000..61dd32b7 --- /dev/null +++ b/lib/features/cards/presentation/cubits/delete_card_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/repositories/cards_repository.dart'; + +part 'delete_card_cubit.freezed.dart'; +part 'delete_card_state.dart'; + +/// Cubit that manages the delete-card command flow. +final class DeleteCardCubit extends Cubit { + final CardsRepository _repository; + + /// Creates an instance of [DeleteCardCubit]. + DeleteCardCubit(this._repository) : super(const DeleteCardState.initial()); + + /// Deletes [cardId]. + Future deleteCard(int cardId) async { + final isInProgress = state.maybeWhen( + inProgress: (_) => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(DeleteCardState.inProgress(cardId)); + + final result = await _repository.deleteCard(cardId); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const DeleteCardState.succeed()); + case Failure(:final error): + emit(DeleteCardState.failed(error)); + } + } +} diff --git a/lib/features/cards/presentation/cubits/delete_card_state.dart b/lib/features/cards/presentation/cubits/delete_card_state.dart new file mode 100644 index 00000000..86e1442e --- /dev/null +++ b/lib/features/cards/presentation/cubits/delete_card_state.dart @@ -0,0 +1,17 @@ +part of 'delete_card_cubit.dart'; + +/// State for [DeleteCardCubit]. +@freezed +sealed class DeleteCardState with _$DeleteCardState { + /// Initial idle state before the delete-card request starts. + const factory DeleteCardState.initial() = _Initial; + + /// State emitted while the delete-card request is in progress. + const factory DeleteCardState.inProgress(int pendingCardId) = _InProgress; + + /// State emitted when delete-card succeeds. + const factory DeleteCardState.succeed() = _Succeed; + + /// State emitted when delete-card fails. + const factory DeleteCardState.failed(CardsFailure failure) = _Failed; +} diff --git a/lib/features/cards/presentation/cubits/save_card_cubit.dart b/lib/features/cards/presentation/cubits/save_card_cubit.dart new file mode 100644 index 00000000..890014e2 --- /dev/null +++ b/lib/features/cards/presentation/cubits/save_card_cubit.dart @@ -0,0 +1,41 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/entities/save_card_payload.dart'; +import '../../domain/repositories/cards_repository.dart'; + +part 'save_card_cubit.freezed.dart'; +part 'save_card_state.dart'; + +/// Cubit that manages save-card submit flow. +final class SaveCardCubit extends Cubit { + final CardsRepository _repository; + + /// Creates an instance of [SaveCardCubit]. + SaveCardCubit(this._repository) : super(const SaveCardState.initial()); + + /// Saves a new card. + Future saveCard({ + required SaveCardPayload payload, + }) async { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(const SaveCardState.inProgress()); + + final result = await _repository.saveCard(payload: payload); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const SaveCardState.succeed()); + case Failure(:final error): + emit(SaveCardState.failed(error)); + } + } +} diff --git a/lib/features/cards/presentation/cubits/save_card_state.dart b/lib/features/cards/presentation/cubits/save_card_state.dart new file mode 100644 index 00000000..ef67f790 --- /dev/null +++ b/lib/features/cards/presentation/cubits/save_card_state.dart @@ -0,0 +1,17 @@ +part of 'save_card_cubit.dart'; + +/// State for [SaveCardCubit]. +@freezed +sealed class SaveCardState with _$SaveCardState { + /// Initial idle state before the save-card request starts. + const factory SaveCardState.initial() = _Initial; + + /// State emitted while the save-card request is in progress. + const factory SaveCardState.inProgress() = _InProgress; + + /// State emitted when save-card succeeds. + const factory SaveCardState.succeed() = _Succeed; + + /// State emitted when save-card fails. + const factory SaveCardState.failed(CardsFailure failure) = _Failed; +} diff --git a/lib/features/cards/presentation/cubits/set_default_card_cubit.dart b/lib/features/cards/presentation/cubits/set_default_card_cubit.dart new file mode 100644 index 00000000..77559b75 --- /dev/null +++ b/lib/features/cards/presentation/cubits/set_default_card_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../core/failures/feature/cards/cards_failure.dart'; +import '../../../../core/result/result.dart'; +import '../../domain/repositories/cards_repository.dart'; + +part 'set_default_card_cubit.freezed.dart'; +part 'set_default_card_state.dart'; + +/// Cubit that manages the default-card command flow. +final class SetDefaultCardCubit extends Cubit { + final CardsRepository _repository; + + /// Creates an instance of [SetDefaultCardCubit]. + SetDefaultCardCubit(this._repository) : super(const SetDefaultCardState.initial()); + + /// Marks [cardId] as default. + Future setDefaultCard(int cardId) async { + final isInProgress = state.maybeWhen( + inProgress: (_) => true, + orElse: () => false, + ); + if (isInProgress) return; + + emit(SetDefaultCardState.inProgress(cardId)); + + final result = await _repository.setDefaultCard(cardId); + if (isClosed) return; + + switch (result) { + case Success(): + emit(const SetDefaultCardState.succeed()); + case Failure(:final error): + emit(SetDefaultCardState.failed(error)); + } + } +} diff --git a/lib/features/cards/presentation/cubits/set_default_card_state.dart b/lib/features/cards/presentation/cubits/set_default_card_state.dart new file mode 100644 index 00000000..8da1dde0 --- /dev/null +++ b/lib/features/cards/presentation/cubits/set_default_card_state.dart @@ -0,0 +1,17 @@ +part of 'set_default_card_cubit.dart'; + +/// State for [SetDefaultCardCubit]. +@freezed +sealed class SetDefaultCardState with _$SetDefaultCardState { + /// Initial idle state before the default-card request starts. + const factory SetDefaultCardState.initial() = _Initial; + + /// State emitted while the default-card request is in progress. + const factory SetDefaultCardState.inProgress(int pendingCardId) = _InProgress; + + /// State emitted when default-card succeeds. + const factory SetDefaultCardState.succeed() = _Succeed; + + /// State emitted when default-card fails. + const factory SetDefaultCardState.failed(CardsFailure failure) = _Failed; +} diff --git a/lib/features/cards/presentation/validators/card_form_validators.dart b/lib/features/cards/presentation/validators/card_form_validators.dart new file mode 100644 index 00000000..7c28f782 --- /dev/null +++ b/lib/features/cards/presentation/validators/card_form_validators.dart @@ -0,0 +1,126 @@ +import '../../../../core/constants/app_strings.dart'; + +/// Shared validators for saved card form fields. +abstract final class CardFormValidators { + static const _hiddenValidationError = 'invalid'; + static const _maxFutureYears = 20; + static final _cardHolderPattern = RegExp(r'^[A-Z ]+$'); + + /// Validates a card number. + static String? cardNumber(String? value) { + final digits = _digitsOnly(value); + if (digits.isEmpty) { + return AppStrings.subscriptionsPaymentCardNumberRequired; + } + if (digits.length != 16) { + return AppStrings.subscriptionsPaymentCardNumberInvalid; + } + return null; + } + + /// Validates a card holder. + static String? cardHolder(String? value) { + final trimmed = _trimmed(value).toUpperCase(); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentCardHolderRequired; + } + if (!_cardHolderPattern.hasMatch(trimmed)) { + return AppStrings.subscriptionsPaymentCardHolderInvalid; + } + return null; + } + + /// Validates a card expiry month. + static String? expiryMonth( + String? value, { + String? yearValue, + DateTime? now, + }) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentExpiryMonthHint; + } + + final month = int.tryParse(trimmed); + if (month == null || month < 1 || month > 12) { + return AppStrings.subscriptionsPaymentExpiryMonthHint; + } + + final year = int.tryParse(_trimmed(yearValue)); + if (year != null) { + final currentDate = now ?? DateTime.now(); + if (_isExpired(month: month, year: year, now: currentDate) || + _isTooFarInFuture(year: year, now: currentDate)) { + return _hiddenValidationError; + } + } + return null; + } + + /// Validates a card expiry year. + static String? expiryYear( + String? value, { + String? monthValue, + DateTime? now, + }) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + if (trimmed.length != 4) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + + final year = int.tryParse(trimmed); + if (year == null) { + return AppStrings.subscriptionsPaymentExpiryYearHint; + } + + final currentDate = now ?? DateTime.now(); + if (year < currentDate.year || _isTooFarInFuture(year: year, now: currentDate)) { + return _hiddenValidationError; + } + + final month = int.tryParse(_trimmed(monthValue)); + if (month != null && + month >= 1 && + month <= 12 && + _isExpired(month: month, year: year, now: currentDate)) { + return _hiddenValidationError; + } + return null; + } + + /// Validates a card CVV. + static String? cvv(String? value) { + final trimmed = _trimmed(value); + if (trimmed.isEmpty) { + return AppStrings.subscriptionsPaymentCvvHint; + } + if (trimmed.length != 3 || int.tryParse(trimmed) == null) { + return AppStrings.subscriptionsPaymentCvvHint; + } + return null; + } + + static String _trimmed(String? value) => value?.trim() ?? ''; + + static String _digitsOnly(String? value) => (value ?? '').replaceAll(RegExp(r'\D'), ''); + + static bool _isExpired({ + required int month, + required int year, + required DateTime now, + }) { + if (year < now.year) return true; + if (year == now.year && month < now.month) return true; + return false; + } + + static bool _isTooFarInFuture({ + required int year, + required DateTime now, + }) { + return year > now.year + _maxFutureYears; + } +} diff --git a/lib/features/cards/presentation/widgets/card_form_text_field.dart b/lib/features/cards/presentation/widgets/card_form_text_field.dart new file mode 100644 index 00000000..8f40a54f --- /dev/null +++ b/lib/features/cards/presentation/widgets/card_form_text_field.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../uikit/themes/colors/app_color_theme.dart'; +import '../../../../uikit/themes/text/app_text_theme.dart'; + +/// Cards-specific text field used only inside the save-card flow. +class CardFormTextField extends StatelessWidget { + /// Text controller. + final TextEditingController controller; + + /// Whether the field is enabled. + final bool enabled; + + /// Field label shown above the input. + final String labelText; + + /// Optional label color override. + final Color? labelColor; + + /// Placeholder text. + final String? hintText; + + /// Optional semantics label when the visible label is hidden. + final String? semanticsLabel; + + /// Keyboard configuration. + final TextInputType keyboardType; + + /// Keyboard action button. + final TextInputAction textInputAction; + + /// Optional validator. + final String? Function(String?)? validator; + + /// Optional submit callback. + final ValueChanged? onFieldSubmitted; + + /// Optional input formatters. + final List? inputFormatters; + + /// Whether to hide text. + final bool obscureText; + + /// Whether the visible label should be rendered. + final bool showLabel; + + /// Whether validation error text should be shown. + final bool showErrorText; + + /// Creates an instance of [CardFormTextField]. + const CardFormTextField({ + required this.controller, + required this.enabled, + required this.labelText, + required this.keyboardType, + required this.textInputAction, + this.labelColor, + this.hintText, + this.semanticsLabel, + this.validator, + this.onFieldSubmitted, + this.inputFormatters, + this.obscureText = false, + this.showLabel = true, + this.showErrorText = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLabel) ...[ + ExcludeSemantics( + child: Text( + labelText, + style: textTheme.label.copyWith(color: labelColor ?? colorTheme.onSurface), + ), + ), + const SizedBox(height: 4), + ], + Semantics( + label: semanticsLabel ?? labelText, + textField: true, + child: TextFormField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + obscureText: obscureText, + textInputAction: textInputAction, + onFieldSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + style: textTheme.body.copyWith(color: colorTheme.onSurface), + cursorColor: colorTheme.primary, + decoration: InputDecoration( + hintText: hintText, + errorStyle: showErrorText + ? null + : const TextStyle( + fontSize: 0, + height: 0, + color: Colors.transparent, + ), + ), + validator: validator, + ), + ), + ], + ); + } +} diff --git a/lib/features/cards/presentation/widgets/save_card_dialog.dart b/lib/features/cards/presentation/widgets/save_card_dialog.dart new file mode 100644 index 00000000..42aa83bc --- /dev/null +++ b/lib/features/cards/presentation/widgets/save_card_dialog.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/constants/app_assets.dart'; +import '../../../../core/constants/app_strings.dart'; +import '../../../../uikit/buttons/button_state.dart'; +import '../../../../uikit/buttons/main_button.dart'; +import '../../../../uikit/buttons/secondary_button.dart'; +import '../../../../uikit/dialogs/app_feedback_dialog.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 '../../../profile/presentation/widgets/profile_dialog_shell.dart'; +import '../../domain/entities/save_card_payload.dart'; +import '../cubits/save_card_cubit.dart'; +import '../validators/card_form_validators.dart'; +import 'card_form_text_field.dart'; + +/// Opens the save-card dialog. +Future showSaveCardDialog( + BuildContext context, { + required SaveCardCubit saveCardCubit, +}) { + return showProfileDialog( + context, + insetPadding: const EdgeInsets.symmetric(horizontal: 24), + contentPadding: EdgeInsets.zero, + child: BlocProvider.value( + value: saveCardCubit, + child: const SaveCardDialog(), + ), + ); +} + +/// Dialog with manual card form for saving a card. +class SaveCardDialog extends StatefulWidget { + /// Creates an instance of [SaveCardDialog]. + const SaveCardDialog({super.key}); + + @override + State createState() => _SaveCardDialogState(); +} + +class _SaveCardDialogState extends State { + final _formKey = GlobalKey(); + final _cardNumberController = TextEditingController(); + final _previewCardNumberController = TextEditingController(); + final _cardHolderController = TextEditingController(); + final _expiryMonthController = TextEditingController(); + final _expiryYearController = TextEditingController(); + final _cvvController = TextEditingController(); + + String get _normalizedCardHolder => _cardHolderController.text.trim().toUpperCase(); + + @override + void initState() { + super.initState(); + _cardNumberController.addListener(_syncPreviewCardNumber); + _cardNumberController.addListener(_handlePreviewChanged); + _cardHolderController.addListener(_handlePreviewChanged); + _expiryMonthController.addListener(_handlePreviewChanged); + _expiryYearController.addListener(_handlePreviewChanged); + } + + @override + void dispose() { + _cardNumberController + ..removeListener(_syncPreviewCardNumber) + ..removeListener(_handlePreviewChanged) + ..dispose(); + _cardHolderController.removeListener(_handlePreviewChanged); + _previewCardNumberController.dispose(); + _cardHolderController.dispose(); + _expiryMonthController.removeListener(_handlePreviewChanged); + _expiryMonthController.dispose(); + _expiryYearController.removeListener(_handlePreviewChanged); + _expiryYearController.dispose(); + _cvvController.dispose(); + super.dispose(); + } + + void _handlePreviewChanged() { + if (!mounted) return; + setState(() {}); + } + + void _syncPreviewCardNumber() { + final digits = _cardNumberController.text.replaceAll(RegExp(r'\D'), ''); + final chunks = []; + for (var index = 0; index < digits.length; index += 4) { + final end = (index + 4).clamp(0, digits.length); + chunks.add(digits.substring(index, end)); + } + _previewCardNumberController.text = chunks.join(' '); + } + + void _submit() { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + context.read().saveCard( + payload: SaveCardPayload( + cardNumber: _cardNumberController.text.replaceAll(RegExp(r'\D'), ''), + cardHolder: _normalizedCardHolder, + expiryMonth: _expiryMonthController.text.trim(), + expiryYear: _expiryYearController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + state.whenOrNull( + succeed: () => Navigator.of(context).pop(true), + failed: (failure) { + if (failure.message.isEmpty) return; + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ); + }, + ); + }, + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: () => true, + orElse: () => false, + ); + final colorTheme = AppColorTheme.of(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(28, 83, 28, 40), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + CardFormTextField( + controller: _cardNumberController, + labelText: AppStrings.subscriptionsPaymentCardNumberLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCardNumberHint, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [const _CardNumberTextInputFormatter()], + validator: CardFormValidators.cardNumber, + ), + const SizedBox(height: 12), + CardFormTextField( + controller: _cardHolderController, + labelText: AppStrings.subscriptionsPaymentCardHolderLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCardHolderHint, + enabled: !isInProgress, + keyboardType: TextInputType.name, + textInputAction: TextInputAction.next, + inputFormatters: [const _CardHolderTextInputFormatter()], + validator: CardFormValidators.cardHolder, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: Text( + AppStrings.subscriptionsPaymentExpiryLabel, + style: AppTextTheme.of(context).label.copyWith( + color: colorTheme.hint, + ), + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: CardFormTextField( + controller: _expiryMonthController, + labelText: AppStrings.subscriptionsPaymentExpiryLabel, + semanticsLabel: AppStrings.subscriptionsPaymentExpiryLabel, + hintText: AppStrings.subscriptionsPaymentExpiryMonthHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + showLabel: false, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(2), + ], + validator: (value) => CardFormValidators.expiryMonth( + value, + yearValue: _expiryYearController.text, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: CardFormTextField( + controller: _expiryYearController, + labelText: AppStrings.subscriptionsPaymentYearLabel, + semanticsLabel: AppStrings.subscriptionsPaymentYearLabel, + hintText: AppStrings.subscriptionsPaymentExpiryYearHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + showLabel: false, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + ], + validator: (value) => CardFormValidators.expiryYear( + value, + monthValue: _expiryMonthController.text, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: CardFormTextField( + controller: _cvvController, + labelText: AppStrings.subscriptionsPaymentCvvLabel, + labelColor: colorTheme.hint, + hintText: AppStrings.subscriptionsPaymentCvvHint, + showErrorText: false, + enabled: !isInProgress, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + obscureText: true, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(3), + ], + validator: CardFormValidators.cvv, + onFieldSubmitted: (_) => _submit(), + ), + ), + ], + ), + const SizedBox(height: 36), + MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: _submit, + child: const Text(AppStrings.cardsAddButton), + ), + const SizedBox(height: 12), + SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: () => Navigator.of(context).pop(false), + child: const Text(AppStrings.profileCancelButton), + ), + ], + ), + ), + ), + Positioned( + top: -100, + left: 0, + right: 0, + child: _CardPreview( + previewCardNumberController: _previewCardNumberController, + cardHolderValue: _normalizedCardHolder, + expiryMonthValue: _expiryMonthController.text.trim(), + expiryYearValue: _expiryYearController.text.trim(), + ), + ), + ], + ); + }, + ); + } +} + +final class _CardPreview extends StatelessWidget { + final TextEditingController previewCardNumberController; + final String cardHolderValue; + final String expiryMonthValue; + final String expiryYearValue; + + const _CardPreview({ + required this.previewCardNumberController, + required this.cardHolderValue, + required this.expiryMonthValue, + required this.expiryYearValue, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + return Stack( + children: [ + const Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: SvgPictureWidget.icon(AppAssets.iconCardBig), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 51, 24, 12), + child: Column( + children: [ + SizedBox( + width: 224, + child: _PreviewNumberField(controller: previewCardNumberController), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppStrings.subscriptionsPaymentCardHolderLabel, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + const SizedBox(height: 4), + Text( + cardHolderValue.isEmpty + ? AppStrings.subscriptionsPaymentCardHolderHint + : cardHolderValue, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + AppStrings.subscriptionsPaymentPreviewExpiryLabel, + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + const SizedBox(height: 4), + Text( + [expiryMonthValue, expiryYearValue] + .where((value) => value.isNotEmpty) + .join('/') + .ifEmpty( + '${AppStrings.subscriptionsPaymentPreviewExpiryMonthLabel}/' + '${AppStrings.subscriptionsPaymentPreviewExpiryYearLabel}', + ), + style: textTheme.label.copyWith(color: colorTheme.onPrimary), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +final class _PreviewNumberField extends StatelessWidget { + final TextEditingController controller; + + const _PreviewNumberField({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final displayText = controller.text.isEmpty + ? AppStrings.subscriptionsPaymentCardNumberHint + : controller.text; + final textColor = controller.text.isEmpty ? colorTheme.outline : colorTheme.onSurface; + + return Semantics( + label: AppStrings.subscriptionsPaymentCardNumberLabel, + textField: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorTheme.outline), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Text( + displayText, + style: textTheme.body.copyWith(color: textColor), + ), + ), + ), + ); + } +} + +final class _CardNumberTextInputFormatter extends TextInputFormatter { + const _CardNumberTextInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limitedDigits = digits.length > 16 ? digits.substring(0, 16) : digits; + final rawSelectionOffset = _countDigitsBefore( + newValue.text, + newValue.selection.baseOffset, + ).clamp(0, limitedDigits.length); + final buffer = StringBuffer(); + + for (var index = 0; index < limitedDigits.length; index++) { + if (index > 0 && index % 4 == 0) { + buffer.write(' '); + } + buffer.write(limitedDigits[index]); + } + + final formatted = buffer.toString(); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: _formattedSelectionOffset( + rawSelectionOffset, + formatted.length, + ), + ), + ); + } + + int _countDigitsBefore(String value, int offset) { + if (offset <= 0) return 0; + + final clampedOffset = offset.clamp(0, value.length); + var count = 0; + for (var index = 0; index < clampedOffset; index++) { + if (_isDigit(value.codeUnitAt(index))) { + count++; + } + } + return count; + } + + int _formattedSelectionOffset(int rawOffset, int formattedLength) { + final spacesBefore = rawOffset == 0 ? 0 : rawOffset ~/ 4; + return (rawOffset + spacesBefore).clamp(0, formattedLength); + } + + bool _isDigit(int codeUnit) => codeUnit >= 48 && codeUnit <= 57; +} + +final class _CardHolderTextInputFormatter extends TextInputFormatter { + const _CardHolderTextInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return newValue.copyWith(text: newValue.text.toUpperCase()); + } +} + +extension on String { + String ifEmpty(String fallback) => isEmpty ? fallback : this; +} diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 05e702de..2245c472 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -22,6 +22,7 @@ import '../widgets/change_password_dialog.dart'; import '../widgets/current_phase_section_widget.dart'; import '../widgets/edit_profile_dialog.dart'; import '../widgets/profile_bottom_section_widget.dart'; +import '../widgets/profile_cards_section_widget.dart'; import '../widgets/profile_parameters_section_widget.dart'; import '../widgets/profile_subscription_section_widget.dart'; import '../widgets/stats/profile_history_dialog.dart'; @@ -138,6 +139,8 @@ class ProfilePage extends StatelessWidget { activeSubscription: state.historySnapshot?.activeSubscription, ), const SizedBox(height: 36), + const ProfileCardsSectionWidget(), + const SizedBox(height: 36), const CurrentPhaseSectionWidget(), const SizedBox(height: 36), const ProfileParametersSectionWidget(), diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 046a84d7..14442bb1 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -6,6 +6,11 @@ import '../../../auth/domain/entities/user.dart'; import '../../../auth/domain/repositories/auth_repository.dart'; import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../../../auth/presentation/cubits/logout_cubit.dart'; +import '../../../cards/domain/repositories/cards_repository.dart'; +import '../../../cards/presentation/cubits/cards_cubit.dart'; +import '../../../cards/presentation/cubits/delete_card_cubit.dart'; +import '../../../cards/presentation/cubits/save_card_cubit.dart'; +import '../../../cards/presentation/cubits/set_default_card_cubit.dart'; import '../../../subscriptions/domain/repositories/subscriptions_repository.dart'; import '../../../subscriptions/presentation/cubits/cancel_subscription_cubit.dart'; import '../../domain/repositories/profile_parameters_repository.dart'; @@ -55,6 +60,20 @@ class ProfilePageBuilder extends StatelessWidget { di(), ), ), + BlocProvider( + create: (_) => CardsCubit( + di(), + )..loadCards(), + ), + BlocProvider( + create: (_) => SaveCardCubit(di()), + ), + BlocProvider( + create: (_) => SetDefaultCardCubit(di()), + ), + BlocProvider( + create: (_) => DeleteCardCubit(di()), + ), BlocProvider.value( value: di(), ), diff --git a/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart b/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart new file mode 100644 index 00000000..b85a3269 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart @@ -0,0 +1,473 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../core/constants/app_assets.dart'; +import '../../../../../core/constants/app_strings.dart'; +import '../../../../../uikit/buttons/button_state.dart'; +import '../../../../../uikit/buttons/main_button.dart'; +import '../../../../../uikit/buttons/secondary_button.dart'; +import '../../../../../uikit/cards/app_card.dart'; +import '../../../../../uikit/dialogs/app_action_dialog.dart'; +import '../../../../../uikit/dialogs/app_feedback_dialog.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 '../../../cards/domain/entities/saved_card.dart'; +import '../../../cards/presentation/cubits/cards_cubit.dart'; +import '../../../cards/presentation/cubits/delete_card_cubit.dart'; +import '../../../cards/presentation/cubits/save_card_cubit.dart'; +import '../../../cards/presentation/cubits/set_default_card_cubit.dart'; +import '../../../cards/presentation/widgets/save_card_dialog.dart'; + +/// Cards section rendered inside `/profile`. +class ProfileCardsSectionWidget extends StatefulWidget { + /// Creates an instance of [ProfileCardsSectionWidget]. + const ProfileCardsSectionWidget({super.key}); + + @override + State createState() => _ProfileCardsSectionWidgetState(); +} + +class _ProfileCardsSectionWidgetState extends State { + bool _isDeleteDialogOpen = false; + + Future _openSaveCardDialog(List cards) async { + if (cards.length >= 3) { + await showAppFeedbackDialog( + context, + title: AppStrings.cardsAddLimitTitle, + message: AppStrings.cardsAddLimitMessage, + ); + return; + } + + final didSave = await showSaveCardDialog( + context, + saveCardCubit: context.read(), + ); + if (!mounted || didSave != true) return; + + unawaited(context.read().loadCards()); + } + + Future _openDeleteDialog(int cardId) async { + if (_isDeleteDialogOpen) return; + _isDeleteDialogOpen = true; + final deleteCardCubit = context.read(); + try { + await showAppActionDialog( + context, + title: AppStrings.cardsDeleteTitle, + description: AppStrings.cardsDeleteDescription, + primaryAction: BlocProvider.value( + value: deleteCardCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: (pendingCardId) => pendingCardId == cardId, + orElse: () => false, + ); + return MainButton( + state: isInProgress ? ButtonState.loading : ButtonState.enabled, + onPressed: () => context.read().deleteCard(cardId), + child: const Text(AppStrings.cardsDeleteConfirmButton), + ); + }, + ), + ), + secondaryAction: BlocProvider.value( + value: deleteCardCubit, + child: BlocBuilder( + builder: (context, state) { + final isInProgress = state.maybeWhen( + inProgress: (_) => true, + orElse: () => false, + ); + return SecondaryButton( + state: isInProgress ? ButtonState.disabled : ButtonState.enabled, + onPressed: _closeActiveDialog, + child: const Text(AppStrings.profileCancelButton), + ); + }, + ), + ), + ); + } finally { + _isDeleteDialogOpen = false; + } + } + + void _closeActiveDialog() { + final navigator = Navigator.of(context, rootNavigator: true); + if (!navigator.canPop()) return; + + Route? topRoute; + navigator.popUntil((route) { + topRoute = route; + return true; + }); + if (topRoute is! PopupRoute) return; + + navigator.pop(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + state.whenOrNull( + succeed: () => unawaited(context.read().loadCards()), + failed: (failure) { + if (failure.message.isEmpty) return; + unawaited( + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ), + ); + }, + ); + }, + ), + BlocListener( + listener: (context, state) { + state.whenOrNull( + succeed: () { + _closeActiveDialog(); + unawaited(context.read().loadCards()); + }, + failed: (failure) { + _closeActiveDialog(); + if (failure.message.isEmpty) return; + unawaited( + showAppFeedbackDialog( + context, + title: AppStrings.feedbackErrorTitle, + message: failure.message, + ), + ); + }, + ); + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + final cards = state.cards; + final isInitialLoading = state.isLoading && cards.isEmpty; + final hasInitialFailure = state.failure != null && cards.isEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.cardsSectionTitle, + style: AppTextTheme.of(context).sectionTitle.copyWith( + color: AppColorTheme.of(context).onSurface, + ), + ), + const SizedBox(height: 16), + if (isInitialLoading) + const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ) + else if (hasInitialFailure) + _CardsRetryState( + onRetryPressed: () => context.read().loadCards(), + ) + else ...[ + if (cards.isNotEmpty) + Column( + children: List.generate(cards.length, (index) { + final card = cards[index]; + return Padding( + padding: EdgeInsets.only(bottom: index == cards.length - 1 ? 0 : 20), + child: _SavedCardDetailsWidget( + card: card, + onMakeDefaultPressed: () => + context.read().setDefaultCard(card.id), + onDeletePressed: () => _openDeleteDialog(card.id), + ), + ); + }), + ), + if (cards.isNotEmpty) const SizedBox(height: 20), + _AddCardButton( + onPressed: () => _openSaveCardDialog(cards), + ), + ], + ], + ); + }, + ), + ); + } +} + +final class _SavedCardDetailsWidget extends StatelessWidget { + final SavedCard card; + final VoidCallback onMakeDefaultPressed; + final VoidCallback onDeletePressed; + + const _SavedCardDetailsWidget({ + required this.card, + required this.onMakeDefaultPressed, + required this.onDeletePressed, + }); + + String _formatExpiryYear(String value) { + if (value.length <= 2) return value; + return value.substring(value.length - 2); + } + + @override + Widget build(BuildContext context) { + final textTheme = AppTextTheme.of(context); + final colorTheme = AppColorTheme.of(context); + final isSetDefaultLoading = context.select( + (cubit) => cubit.state.maybeWhen( + inProgress: (pendingCardId) => pendingCardId == card.id, + orElse: () => false, + ), + ); + final isDeleteLoading = context.select( + (cubit) => cubit.state.maybeWhen( + inProgress: (pendingCardId) => pendingCardId == card.id, + orElse: () => false, + ), + ); + final isAnyDefaultInProgress = context.select( + (cubit) => cubit.state.maybeWhen( + inProgress: (_) => true, + orElse: () => false, + ), + ); + final isAnyDeleteInProgress = context.select( + (cubit) => cubit.state.maybeWhen( + inProgress: (_) => true, + orElse: () => false, + ), + ); + + return AppCard( + contentPadding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SvgPictureWidget.icon(AppAssets.iconCardSmall), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '**** ${card.lastFour}', + style: textTheme.bodyMedium.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w500, + color: colorTheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + '${card.expiryMonth}/${_formatExpiryYear(card.expiryYear)}', + style: textTheme.bodyMedium.copyWith( + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w400, + color: colorTheme.hint, + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + card.holderName, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SecondaryButton( + state: card.isDefault + ? ButtonState.disabled + : isSetDefaultLoading + ? ButtonState.loading + : isAnyDefaultInProgress || isAnyDeleteInProgress + ? ButtonState.disabled + : ButtonState.enabled, + onPressed: onMakeDefaultPressed, + child: Text( + card.isDefault + ? AppStrings.cardsDefaultButton + : AppStrings.cardsMakeDefaultButton, + ), + ), + ), + const SizedBox(width: 16), + _DeleteCardButton( + isLoading: isDeleteLoading, + isDisabled: isAnyDefaultInProgress || (isAnyDeleteInProgress && !isDeleteLoading), + onPressed: onDeletePressed, + ), + ], + ), + ], + ), + ); + } +} + +final class _DeleteCardButton extends StatelessWidget { + final bool isLoading; + final bool isDisabled; + final VoidCallback onPressed; + + const _DeleteCardButton({ + required this.isLoading, + required this.isDisabled, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final isInactive = isDisabled || isLoading; + + return Semantics( + button: true, + enabled: !isInactive, + label: AppStrings.cardsDeleteConfirmButton, + child: IgnorePointer( + ignoring: isInactive, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const .all(10), + decoration: BoxDecoration( + color: isInactive ? colorTheme.disabled : colorTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorTheme.darkHint), + ), + child: Center( + child: SvgPictureWidget.icon( + AppAssets.iconCloseVariant, + color: colorTheme.onSurface, + ), + ), + ), + ), + ), + ); + } +} + +final class _CardsRetryState extends StatelessWidget { + final VoidCallback onRetryPressed; + + const _CardsRetryState({ + required this.onRetryPressed, + }); + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppStrings.cardsLoadFailed, + textAlign: TextAlign.center, + style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface), + ), + const SizedBox(height: 16), + MainButton( + onPressed: onRetryPressed, + child: const Text(AppStrings.retryButton), + ), + ], + ); + } +} + +final class _AddCardButton extends StatefulWidget { + final VoidCallback onPressed; + + const _AddCardButton({ + required this.onPressed, + }); + + @override + State<_AddCardButton> createState() => _AddCardButtonState(); +} + +class _AddCardButtonState extends State<_AddCardButton> { + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + final colorTheme = AppColorTheme.of(context); + final textTheme = AppTextTheme.of(context); + final foreground = _isPressed ? colorTheme.primary : colorTheme.onSurface; + final borderColor = _isPressed ? colorTheme.primary : colorTheme.outline.withValues(alpha: 0.6); + + return DecoratedBox( + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: InkWell( + onTap: widget.onPressed, + onHighlightChanged: (value) { + if (!mounted) return; + setState(() => _isPressed = value); + }, + borderRadius: BorderRadius.circular(8), + child: SizedBox( + height: 38, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppStrings.cardsAddButton, + style: textTheme.label.copyWith(color: foreground), + ), + const SizedBox(width: 2), + SvgPictureWidget.icon( + AppAssets.iconPlus, + color: foreground, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/test/features/cards/data/repositories/cards_repository_impl_test.dart b/test/features/cards/data/repositories/cards_repository_impl_test.dart new file mode 100644 index 00000000..206fc2af --- /dev/null +++ b/test/features/cards/data/repositories/cards_repository_impl_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/cards/cards_failure.dart'; +import 'package:moveup_flutter/core/utils/logger/app_logger.dart'; +import 'package:moveup_flutter/features/cards/data/dto/save_card_request_dto.dart'; +import 'package:moveup_flutter/features/cards/data/remote/cards_api_client.dart'; +import 'package:moveup_flutter/features/cards/data/repositories/cards_repository_impl.dart'; +import 'package:moveup_flutter/features/cards/domain/repositories/cards_repository.dart'; + +import '../../support/cards_dto_fixtures.dart'; +import 'cards_repository_impl_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockAppLogger logger; + late MockCardsApiClient apiClient; + late CardsRepository repository; + + setUp(() { + logger = MockAppLogger(); + apiClient = MockCardsApiClient(); + repository = CardsRepositoryImpl(logger, apiClient); + }); + + group('CardsRepositoryImpl', () { + group('CardsRepositoryImpl.getCards', () { + test('returns success(cards) when api succeeds', () async { + final responseDto = createSavedCardsResponseDto(); + final expectedCards = createSavedCards(); + when(apiClient.getCards()).thenAnswer((_) async => responseDto); + + final result = await repository.getCards(); + + expect(result.isSuccess, isTrue); + expect(result.success, expectedCards); + expect(result.success!.first.isDefault, isTrue); + + verify(apiClient.getCards()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('keeps default card first in returned cards list', () async { + final responseDto = createSavedCardsResponseDto(); + when(apiClient.getCards()).thenAnswer((_) async => responseDto); + + final result = await repository.getCards(); + + expect(result.isSuccess, isTrue); + expect(result.success!.map((card) => card.id), [1, 2]); + + verify(apiClient.getCards()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns CardsRequestFailure when api returns server error', () async { + final exception = createCardsDioBadResponseException( + path: '/payment/cards', + statusCode: 500, + ); + when(apiClient.getCards()).thenThrow(exception); + + final result = await repository.getCards(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getCards()).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownCardsFailure when unexpected exception occurs', () async { + final exception = Exception('unexpected_error'); + when(apiClient.getCards()).thenThrow(exception); + + final result = await repository.getCards(); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.getCards()).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('CardsRepositoryImpl.saveCard', () { + test('returns success when save api succeeds', () async { + when(apiClient.saveCard(any)).thenAnswer((_) async {}); + + final result = await repository.saveCard(payload: testSaveCardPayload); + + expect(result.isSuccess, isTrue); + verify( + apiClient.saveCard( + argThat( + isA() + .having((request) => request.cardNumber, 'cardNumber', '4111111111111111') + .having((request) => request.cardHolder, 'cardHolder', 'IVAN IVANOV') + .having((request) => request.expiryMonth, 'expiryMonth', '12') + .having((request) => request.expiryYear, 'expiryYear', '2029'), + ), + ), + ).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns CardsValidationFailure on validation error', () async { + final exception = createCardsDioBadResponseException( + path: '/payment/cards/save', + statusCode: 422, + code: 'validation_failed', + message: 'validation_failed', + errors: { + 'card_number': ['Введите корректный номер карты'], + 'card_holder': ['Введите имя держателя'], + }, + ); + when(apiClient.saveCard(any)).thenThrow(exception); + + final result = await repository.saveCard(payload: testSaveCardPayload); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect( + result.failure!.message, + 'Введите корректный номер карты\nВведите имя держателя', + ); + + verify(apiClient.saveCard(any)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns CardsRequestFailure when save api returns server error', () async { + final exception = createCardsDioBadResponseException( + path: '/payment/cards/save', + statusCode: 500, + ); + when(apiClient.saveCard(any)).thenThrow(exception); + + final result = await repository.saveCard(payload: testSaveCardPayload); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveCard(any)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownCardsFailure when save throws unexpected exception', () async { + final exception = Exception('unexpected_save_error'); + when(apiClient.saveCard(any)).thenThrow(exception); + + final result = await repository.saveCard(payload: testSaveCardPayload); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.saveCard(any)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('CardsRepositoryImpl.setDefaultCard', () { + test('returns success when default api succeeds', () async { + when(apiClient.setDefaultCard(2)).thenAnswer((_) async {}); + + final result = await repository.setDefaultCard(2); + + expect(result.isSuccess, isTrue); + verify(apiClient.setDefaultCard(2)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns CardsRequestFailure when default api returns server error', () async { + final exception = createCardsDioBadResponseException( + path: '/payment/cards/2/default', + statusCode: 500, + ); + when(apiClient.setDefaultCard(2)).thenThrow(exception); + + final result = await repository.setDefaultCard(2); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.setDefaultCard(2)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownCardsFailure when default throws unexpected exception', () async { + final exception = Exception('unexpected_default_error'); + when(apiClient.setDefaultCard(2)).thenThrow(exception); + + final result = await repository.setDefaultCard(2); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.setDefaultCard(2)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + + group('CardsRepositoryImpl.deleteCard', () { + test('returns success when delete api succeeds', () async { + when(apiClient.deleteCard(2)).thenAnswer((_) async {}); + + final result = await repository.deleteCard(2); + + expect(result.isSuccess, isTrue); + verify(apiClient.deleteCard(2)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns CardsRequestFailure when delete api returns server error', () async { + final exception = createCardsDioBadResponseException( + path: '/payment/cards/2', + statusCode: 500, + ); + when(apiClient.deleteCard(2)).thenThrow(exception); + + final result = await repository.deleteCard(2); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.deleteCard(2)).called(1); + verifyNever(logger.e(any, any, any)); + verifyNoMoreInteractions(apiClient); + }); + + test('returns UnknownCardsFailure when delete throws unexpected exception', () async { + final exception = Exception('unexpected_delete_error'); + when(apiClient.deleteCard(2)).thenThrow(exception); + + final result = await repository.deleteCard(2); + + expect(result.isFailure, isTrue); + expect(result.failure, isA()); + expect(result.failure!.parentException, exception); + + verify(apiClient.deleteCard(2)).called(1); + verify(logger.e(any, exception, any)).called(1); + verifyNoMoreInteractions(apiClient); + }); + }); + }); +} diff --git a/test/features/cards/presentation/cubits/cards_cubit_test.dart b/test/features/cards/presentation/cubits/cards_cubit_test.dart new file mode 100644 index 00000000..d517b87d --- /dev/null +++ b/test/features/cards/presentation/cubits/cards_cubit_test.dart @@ -0,0 +1,93 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/cards/cards_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/cards/domain/entities/saved_card.dart'; +import 'package:moveup_flutter/features/cards/domain/repositories/cards_repository.dart'; +import 'package:moveup_flutter/features/cards/presentation/cubits/cards_cubit.dart'; + +import '../../support/cards_dto_fixtures.dart'; +import 'cards_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockCardsRepository repository; + late CardsCubit cubit; + + setUp(() { + repository = MockCardsRepository(); + cubit = CardsCubit(repository); + provideDummy, CardsFailure>>( + Success, CardsFailure>(createSavedCards()), + ); + }); + + group('CardsCubit', () { + blocTest( + 'loads cards when request succeeds', + setUp: () => when(repository.getCards()).thenAnswer( + (_) async => Success, CardsFailure>(createSavedCards()), + ), + build: () => cubit, + act: (cubit) => cubit.loadCards(), + expect: () => [ + const CardsState(isLoading: true), + CardsState(cards: createSavedCards()), + ], + verify: (_) => verify(repository.getCards()).called(1), + ); + + blocTest( + 'emits failed state when request fails', + setUp: () => when(repository.getCards()).thenAnswer( + (_) async => const Failure, CardsFailure>( + CardsRequestFailure('error_message'), + ), + ), + build: () => cubit, + act: (cubit) => cubit.loadCards(), + expect: () => const [ + CardsState(isLoading: true), + CardsState(failure: CardsRequestFailure('error_message')), + ], + verify: (_) => verify(repository.getCards()).called(1), + ); + + blocTest( + 'emits loading only once when loadCards is called twice', + setUp: () => when(repository.getCards()).thenAnswer( + (_) async => Success, CardsFailure>(createSavedCards()), + ), + build: () => cubit, + act: (cubit) { + cubit.loadCards(); + cubit.loadCards(); + }, + expect: () => [ + const CardsState(isLoading: true), + CardsState(cards: createSavedCards()), + ], + verify: (_) => verify(repository.getCards()).called(1), + ); + + blocTest( + 'keeps previous cards while refresh is in progress', + setUp: () => when(repository.getCards()).thenAnswer( + (_) async => Success, CardsFailure>(createSavedCards()), + ), + build: () => cubit, + seed: () => CardsState(cards: createSavedCards()), + act: (cubit) => cubit.loadCards(), + expect: () => [ + CardsState( + isLoading: true, + cards: createSavedCards(), + ), + CardsState(cards: createSavedCards()), + ], + verify: (_) => verify(repository.getCards()).called(1), + ); + }); +} diff --git a/test/features/cards/presentation/cubits/delete_card_cubit_test.dart b/test/features/cards/presentation/cubits/delete_card_cubit_test.dart new file mode 100644 index 00000000..52b65944 --- /dev/null +++ b/test/features/cards/presentation/cubits/delete_card_cubit_test.dart @@ -0,0 +1,73 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/cards/cards_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/cards/domain/repositories/cards_repository.dart'; +import 'package:moveup_flutter/features/cards/presentation/cubits/delete_card_cubit.dart'; + +import 'delete_card_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockCardsRepository repository; + late DeleteCardCubit cubit; + + setUp(() { + repository = MockCardsRepository(); + cubit = DeleteCardCubit(repository); + provideDummy>( + const Success(null), + ); + }); + + group('DeleteCardCubit', () { + const cardsFailure = CardsRequestFailure('error_message'); + + blocTest( + 'emits succeed when delete succeeds', + setUp: () => when( + repository.deleteCard(2), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.deleteCard(2), + expect: () => const [ + DeleteCardState.inProgress(2), + DeleteCardState.succeed(), + ], + verify: (_) => verify(repository.deleteCard(2)).called(1), + ); + + blocTest( + 'emits failed when delete fails', + setUp: () => when( + repository.deleteCard(2), + ).thenAnswer((_) async => const Failure(cardsFailure)), + build: () => cubit, + act: (cubit) => cubit.deleteCard(2), + expect: () => const [ + DeleteCardState.inProgress(2), + DeleteCardState.failed(cardsFailure), + ], + verify: (_) => verify(repository.deleteCard(2)).called(1), + ); + + blocTest( + 'emits inProgress only once when deleteCard is called twice', + setUp: () => when( + repository.deleteCard(2), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.deleteCard(2); + cubit.deleteCard(2); + }, + expect: () => const [ + DeleteCardState.inProgress(2), + DeleteCardState.succeed(), + ], + verify: (_) => verify(repository.deleteCard(2)).called(1), + ); + }); +} diff --git a/test/features/cards/presentation/cubits/save_card_cubit_test.dart b/test/features/cards/presentation/cubits/save_card_cubit_test.dart new file mode 100644 index 00000000..8bbd328a --- /dev/null +++ b/test/features/cards/presentation/cubits/save_card_cubit_test.dart @@ -0,0 +1,74 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/cards/cards_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/cards/domain/repositories/cards_repository.dart'; +import 'package:moveup_flutter/features/cards/presentation/cubits/save_card_cubit.dart'; + +import '../../support/cards_dto_fixtures.dart'; +import 'save_card_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockCardsRepository repository; + late SaveCardCubit cubit; + + setUp(() { + repository = MockCardsRepository(); + cubit = SaveCardCubit(repository); + provideDummy>( + const Success(null), + ); + }); + + group('SaveCardCubit', () { + const cardsFailure = CardsRequestFailure('error_message'); + + blocTest( + 'emits succeed when save succeeds', + setUp: () => when( + repository.saveCard(payload: testSaveCardPayload), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) => cubit.saveCard(payload: testSaveCardPayload), + expect: () => const [ + SaveCardState.inProgress(), + SaveCardState.succeed(), + ], + verify: (_) => verify(repository.saveCard(payload: testSaveCardPayload)).called(1), + ); + + blocTest( + 'emits failed when save fails', + setUp: () => when( + repository.saveCard(payload: testSaveCardPayload), + ).thenAnswer((_) async => const Failure(cardsFailure)), + build: () => cubit, + act: (cubit) => cubit.saveCard(payload: testSaveCardPayload), + expect: () => const [ + SaveCardState.inProgress(), + SaveCardState.failed(cardsFailure), + ], + verify: (_) => verify(repository.saveCard(payload: testSaveCardPayload)).called(1), + ); + + blocTest( + 'emits inProgress only once when saveCard is called twice', + setUp: () => when( + repository.saveCard(payload: testSaveCardPayload), + ).thenAnswer((_) async => const Success(null)), + build: () => cubit, + act: (cubit) { + cubit.saveCard(payload: testSaveCardPayload); + cubit.saveCard(payload: testSaveCardPayload); + }, + expect: () => const [ + SaveCardState.inProgress(), + SaveCardState.succeed(), + ], + verify: (_) => verify(repository.saveCard(payload: testSaveCardPayload)).called(1), + ); + }); +} diff --git a/test/features/cards/presentation/cubits/set_default_card_cubit_test.dart b/test/features/cards/presentation/cubits/set_default_card_cubit_test.dart new file mode 100644 index 00000000..efa7570c --- /dev/null +++ b/test/features/cards/presentation/cubits/set_default_card_cubit_test.dart @@ -0,0 +1,73 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:moveup_flutter/core/failures/feature/cards/cards_failure.dart'; +import 'package:moveup_flutter/core/result/result.dart'; +import 'package:moveup_flutter/features/cards/domain/repositories/cards_repository.dart'; +import 'package:moveup_flutter/features/cards/presentation/cubits/set_default_card_cubit.dart'; + +import 'set_default_card_cubit_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + late MockCardsRepository repository; + late SetDefaultCardCubit cubit; + + setUp(() { + repository = MockCardsRepository(); + cubit = SetDefaultCardCubit(repository); + provideDummy>( + const Success(null), + ); + }); + + group('SetDefaultCardCubit', () { + const cardsFailure = CardsRequestFailure('error_message'); + + blocTest( + 'emits succeed when setDefault succeeds', + setUp: () => when(repository.setDefaultCard(2)).thenAnswer( + (_) async => const Success(null), + ), + build: () => cubit, + act: (cubit) => cubit.setDefaultCard(2), + expect: () => const [ + SetDefaultCardState.inProgress(2), + SetDefaultCardState.succeed(), + ], + verify: (_) => verify(repository.setDefaultCard(2)).called(1), + ); + + blocTest( + 'emits failed when setDefault fails', + setUp: () => when( + repository.setDefaultCard(2), + ).thenAnswer((_) async => const Failure(cardsFailure)), + build: () => cubit, + act: (cubit) => cubit.setDefaultCard(2), + expect: () => const [ + SetDefaultCardState.inProgress(2), + SetDefaultCardState.failed(cardsFailure), + ], + verify: (_) => verify(repository.setDefaultCard(2)).called(1), + ); + + blocTest( + 'emits inProgress only once when setDefaultCard is called twice', + setUp: () => when(repository.setDefaultCard(2)).thenAnswer( + (_) async => const Success(null), + ), + build: () => cubit, + act: (cubit) { + cubit.setDefaultCard(2); + cubit.setDefaultCard(2); + }, + expect: () => const [ + SetDefaultCardState.inProgress(2), + SetDefaultCardState.succeed(), + ], + verify: (_) => verify(repository.setDefaultCard(2)).called(1), + ); + }); +} diff --git a/test/features/cards/presentation/validators/card_form_validators_test.dart b/test/features/cards/presentation/validators/card_form_validators_test.dart new file mode 100644 index 00000000..75908898 --- /dev/null +++ b/test/features/cards/presentation/validators/card_form_validators_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moveup_flutter/features/cards/presentation/validators/card_form_validators.dart'; + +void main() { + group('CardFormValidators.cardNumber', () { + const requiredMessage = 'Введите номер карты'; + const invalidMessage = 'Номер карты должен состоять из 16 цифр'; + + test('returns required error when value is empty', () { + expect(CardFormValidators.cardNumber(null), requiredMessage); + expect(CardFormValidators.cardNumber(''), requiredMessage); + expect(CardFormValidators.cardNumber(' '), requiredMessage); + }); + + test('returns invalid error when card number length is not 16 digits', () { + expect(CardFormValidators.cardNumber('1234'), invalidMessage); + expect(CardFormValidators.cardNumber('1234 5678 9012 345'), invalidMessage); + expect(CardFormValidators.cardNumber('1234 5678 9012 34567'), invalidMessage); + }); + + test('returns null when card number contains exactly 16 digits', () { + expect(CardFormValidators.cardNumber('1234 5678 9012 3456'), isNull); + }); + }); + + group('CardFormValidators.cardHolder', () { + const requiredMessage = 'Введите имя держателя карты'; + const invalidMessage = + 'Имя держателя карты должно содержать только заглавные латинские буквы и пробелы'; + + test('returns required error when value is empty', () { + expect(CardFormValidators.cardHolder(null), requiredMessage); + expect(CardFormValidators.cardHolder(''), requiredMessage); + expect(CardFormValidators.cardHolder(' '), requiredMessage); + }); + + test('returns invalid error when value contains non-latin or non-letter symbols', () { + expect(CardFormValidators.cardHolder('ИВАН ИВАНОВ'), invalidMessage); + expect(CardFormValidators.cardHolder('IVAN1 IVANOV'), invalidMessage); + expect(CardFormValidators.cardHolder('IVAN- IVANOV'), invalidMessage); + }); + + test('returns null when value is valid', () { + expect(CardFormValidators.cardHolder('IVAN IVANOV'), isNull); + expect(CardFormValidators.cardHolder('Ivan Ivanov'), isNull); + expect(CardFormValidators.cardHolder(' IVAN IVANOV '), isNull); + expect(CardFormValidators.cardHolder('IVAN'), isNull); + }); + }); + + group('CardFormValidators.expiryMonth', () { + const invalidMessage = 'Месяц'; + final fixedNow = DateTime(2026, 4, 2); + + test('returns invalid error when value is empty', () { + expect(CardFormValidators.expiryMonth(null), invalidMessage); + expect(CardFormValidators.expiryMonth(''), invalidMessage); + expect(CardFormValidators.expiryMonth(' '), invalidMessage); + }); + + test('returns invalid error when month is out of range', () { + expect(CardFormValidators.expiryMonth('0'), invalidMessage); + expect(CardFormValidators.expiryMonth('13'), invalidMessage); + expect(CardFormValidators.expiryMonth('99'), invalidMessage); + }); + + test('returns null when month is in range', () { + expect(CardFormValidators.expiryMonth('1'), isNull); + expect(CardFormValidators.expiryMonth('12'), isNull); + }); + + test('returns hidden error when month is earlier than current month in current year', () { + expect( + CardFormValidators.expiryMonth( + '3', + yearValue: '2026', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns null when month is current or future in current year', () { + expect( + CardFormValidators.expiryMonth( + '4', + yearValue: '2026', + now: fixedNow, + ), + isNull, + ); + expect( + CardFormValidators.expiryMonth( + '5', + yearValue: '2026', + now: fixedNow, + ), + isNull, + ); + }); + + test('returns hidden error when year is unrealistically far in the future', () { + expect( + CardFormValidators.expiryMonth( + '5', + yearValue: '9999', + now: fixedNow, + ), + isNotNull, + ); + }); + }); + + group('CardFormValidators.expiryYear', () { + const invalidMessage = 'Год'; + final fixedNow = DateTime(2026, 4, 2); + + test('returns invalid error when value is empty', () { + expect(CardFormValidators.expiryYear(null), invalidMessage); + expect(CardFormValidators.expiryYear(''), invalidMessage); + expect(CardFormValidators.expiryYear(' '), invalidMessage); + }); + + test('returns invalid error when year length is not 4', () { + expect(CardFormValidators.expiryYear('24'), invalidMessage); + expect(CardFormValidators.expiryYear('202'), invalidMessage); + expect(CardFormValidators.expiryYear('20245'), invalidMessage); + }); + + test('returns null when year length is 4', () { + expect(CardFormValidators.expiryYear('2026'), isNull); + expect(CardFormValidators.expiryYear(' 2026 '), isNull); + }); + + test('returns hidden error when year is before current year', () { + expect( + CardFormValidators.expiryYear( + '2025', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns hidden error when month is already in the past for current year', () { + expect( + CardFormValidators.expiryYear( + '2026', + monthValue: '3', + now: fixedNow, + ), + isNotNull, + ); + }); + + test('returns null when current year is paired with current or future month', () { + expect( + CardFormValidators.expiryYear( + '2026', + monthValue: '4', + now: fixedNow, + ), + isNull, + ); + expect( + CardFormValidators.expiryYear( + '2026', + monthValue: '12', + now: fixedNow, + ), + isNull, + ); + }); + + test('returns hidden error when year is unrealistically far in the future', () { + expect( + CardFormValidators.expiryYear( + '9999', + now: fixedNow, + ), + isNotNull, + ); + }); + }); + + group('CardFormValidators.cvv', () { + const invalidMessage = '***'; + + test('returns invalid error when value is empty', () { + expect(CardFormValidators.cvv(null), invalidMessage); + expect(CardFormValidators.cvv(''), invalidMessage); + expect(CardFormValidators.cvv(' '), invalidMessage); + }); + + test('returns invalid error when cvv length is not 3', () { + expect(CardFormValidators.cvv('1'), invalidMessage); + expect(CardFormValidators.cvv('12'), invalidMessage); + expect(CardFormValidators.cvv('1234'), invalidMessage); + }); + + test('returns invalid error when cvv contains non-digit characters', () { + expect(CardFormValidators.cvv('abc'), invalidMessage); + expect(CardFormValidators.cvv('12a'), invalidMessage); + }); + + test('returns null when cvv length is 3', () { + expect(CardFormValidators.cvv('123'), isNull); + expect(CardFormValidators.cvv(' 123 '), isNull); + }); + }); +} diff --git a/test/features/cards/support/cards_dto_fixtures.dart b/test/features/cards/support/cards_dto_fixtures.dart new file mode 100644 index 00000000..dd36391f --- /dev/null +++ b/test/features/cards/support/cards_dto_fixtures.dart @@ -0,0 +1,80 @@ +import 'package:dio/dio.dart'; +import 'package:moveup_flutter/features/cards/data/dto/saved_card_dto.dart'; +import 'package:moveup_flutter/features/cards/data/dto/saved_cards_response_dto.dart'; +import 'package:moveup_flutter/features/cards/domain/entities/save_card_payload.dart'; +import 'package:moveup_flutter/features/cards/domain/entities/saved_card.dart'; + +/// Test fixture for cards response DTO. +SavedCardsResponseDto createSavedCardsResponseDto() => SavedCardsResponseDto( + data: [ + SavedCardDto( + id: 1, + cardHolder: 'IVAN IVANOV', + cardLastFour: '4585', + expiryMonth: '01', + expiryYear: '2054', + isDefault: true, + ), + SavedCardDto( + id: 2, + cardHolder: 'IVAN IVANOV', + cardLastFour: '7585', + expiryMonth: '01', + expiryYear: '2044', + isDefault: false, + ), + ], +); + +/// Test fixture for cards domain entities. +List createSavedCards() => const [ + SavedCard( + id: 1, + holderName: 'IVAN IVANOV', + lastFour: '4585', + expiryMonth: '01', + expiryYear: '2054', + isDefault: true, + ), + SavedCard( + id: 2, + holderName: 'IVAN IVANOV', + lastFour: '7585', + expiryMonth: '01', + expiryYear: '2044', + isDefault: false, + ), +]; + +/// Test fixture for saving a new card. +const testSaveCardPayload = SaveCardPayload( + cardNumber: '4111111111111111', + cardHolder: 'IVAN IVANOV', + expiryMonth: '12', + expiryYear: '2029', +); + +/// Creates a bad-response [DioException] for cards API tests. +DioException createCardsDioBadResponseException({ + required String path, + required int statusCode, + String code = 'server_error', + String message = 'error', + Map>? errors, +}) { + final requestOptions = RequestOptions(path: path); + return DioException( + requestOptions: requestOptions, + response: Response>( + requestOptions: requestOptions, + statusCode: statusCode, + data: { + 'success': false, + 'message': message, + 'code': code, + 'errors': ?errors, + }, + ), + type: DioExceptionType.badResponse, + ); +} From 0d85d34db696e372e659c2dfdbb7ab4749a69c4e Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:02:48 +0700 Subject: [PATCH 10/13] fix(profile-ui): add missed content padding for each dialog in user section --- .../profile/presentation/widgets/change_password_dialog.dart | 1 + .../profile/presentation/widgets/edit_profile_dialog.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/features/profile/presentation/widgets/change_password_dialog.dart b/lib/features/profile/presentation/widgets/change_password_dialog.dart index e3535172..9337d173 100644 --- a/lib/features/profile/presentation/widgets/change_password_dialog.dart +++ b/lib/features/profile/presentation/widgets/change_password_dialog.dart @@ -29,6 +29,7 @@ Future showChangePasswordDialog(BuildContext contex return showProfileDialog( context, insetPadding: const EdgeInsets.symmetric(horizontal: 19.5), + contentPadding: const .symmetric(horizontal: 28, vertical: 40), child: BlocProvider( create: (_) => ChangePasswordCubit(di()), child: const ChangePasswordDialog(), diff --git a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart index 0e08e742..2140352a 100644 --- a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart +++ b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart @@ -29,6 +29,7 @@ Future showEditProfileDialog( return showProfileDialog( context, insetPadding: const EdgeInsets.symmetric(horizontal: 11.5), + contentPadding: const .symmetric(horizontal: 28, vertical: 40), child: BlocProvider( create: (_) => UpdateProfileCubit(di()), child: EditProfileDialog(user: user), From 27a5e9a92056dc16e63acd8bc0b5ce188ab62e26 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:15:41 +0700 Subject: [PATCH 11/13] fix(profile-cards-ui): polish add card button to match layout --- .../widgets/profile_cards_section_widget.dart | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart b/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart index b85a3269..29f1f4f6 100644 --- a/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_cards_section_widget.dart @@ -435,21 +435,23 @@ class _AddCardButtonState extends State<_AddCardButton> { final foreground = _isPressed ? colorTheme.primary : colorTheme.onSurface; final borderColor = _isPressed ? colorTheme.primary : colorTheme.outline.withValues(alpha: 0.6); - return DecoratedBox( - decoration: BoxDecoration( - color: colorTheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), - ), - child: InkWell( - onTap: widget.onPressed, - onHighlightChanged: (value) { - if (!mounted) return; - setState(() => _isPressed = value); - }, - borderRadius: BorderRadius.circular(8), - child: SizedBox( - height: 38, + return Semantics( + button: true, + label: AppStrings.cardsAddButton, + child: Container( + padding: const .symmetric(vertical: 12), + decoration: BoxDecoration( + color: colorTheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: InkWell( + onTap: widget.onPressed, + onHighlightChanged: (value) { + if (!mounted) return; + setState(() => _isPressed = value); + }, + borderRadius: BorderRadius.circular(8), child: Center( child: Row( mainAxisSize: MainAxisSize.min, From eb94817c6a5b17312150964959988e63b9efcbe8 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:22:44 +0700 Subject: [PATCH 12/13] chore: auto format files --- .../profile_parameters/profile_parameters_gender.dart | 3 ++- .../cubits/subscription_payment_cubit_test.dart | 11 ++++++----- .../presentation/cubits/subscriptions_cubit_test.dart | 5 +++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart index 29655f92..e9c25312 100644 --- a/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart +++ b/lib/features/profile/domain/entities/profile_parameters/profile_parameters_gender.dart @@ -4,7 +4,8 @@ enum ProfileParametersGender { male('male'), /// Female gender. - female('female'); + female('female') + ; /// Backend request value. final String requestValue; diff --git a/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart b/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart index bcf4fbab..db2fae54 100644 --- a/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart +++ b/test/features/subscriptions/presentation/cubits/subscription_payment_cubit_test.dart @@ -43,11 +43,12 @@ void main() { blocTest( 'emits failed when pay fails', - setUp: () => when( - repository.paySubscription(payload: testSubscriptionPaymentPayload), - ).thenAnswer( - (_) async => const Failure(subscriptionsFailure), - ), + setUp: () => + when( + repository.paySubscription(payload: testSubscriptionPaymentPayload), + ).thenAnswer( + (_) async => const Failure(subscriptionsFailure), + ), build: () => cubit, act: (cubit) => cubit.pay(payload: testSubscriptionPaymentPayload), expect: () => const [ diff --git a/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart b/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart index 5bcddf05..f1f9e15b 100644 --- a/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart +++ b/test/features/subscriptions/presentation/cubits/subscriptions_cubit_test.dart @@ -63,8 +63,9 @@ void main() { blocTest( 'emits failed(subscriptionsFailure) when loadSubscriptions fails', setUp: () => when(repository.getSubscriptions()).thenAnswer( - (_) async => - const Failure, SubscriptionsFailure>(subscriptionsFailure), + (_) async => const Failure, SubscriptionsFailure>( + subscriptionsFailure, + ), ), build: () => subscriptionsCubit, act: (cubit) => cubit.loadSubscriptions(), From 5236c7c8b15957bf76227ee977e430822a453e2f Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Sat, 4 Apr 2026 11:37:47 +0700 Subject: [PATCH 13/13] fix(cards-data): map request failures correctly --- .../data/mappers/cards_failure_mapper.dart | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lib/features/cards/data/mappers/cards_failure_mapper.dart b/lib/features/cards/data/mappers/cards_failure_mapper.dart index 6d58f293..ef359529 100644 --- a/lib/features/cards/data/mappers/cards_failure_mapper.dart +++ b/lib/features/cards/data/mappers/cards_failure_mapper.dart @@ -6,31 +6,34 @@ import '../../../../core/failures/network/network_failure.dart'; extension CardsFailureMapper on NetworkFailure { /// Maps a [NetworkFailure] into a cards-specific failure. CardsFailure toCardsFailure() { - if (this case ValidationFailure(:final errors)) { - final validationMessage = buildValidationMessage( - errors, - fallbackMessage: const CardsValidationFailure().message, - ); - return CardsValidationFailure( - message: validationMessage, - parentException: parentException, - stackTrace: stackTrace, - ); + final fieldErrors = switch (this) { + ValidationFailure(:final errors) => errors, + _ => const >{}, + }; + final validationMessage = buildValidationMessage( + fieldErrors, + fallbackMessage: const CardsValidationFailure().message, + ); + + switch (code) { + case 'validation_failed': + return CardsValidationFailure( + message: validationMessage, + parentException: parentException, + stackTrace: stackTrace, + ); } + return switch (this) { - ValidationFailure() => CardsValidationFailure( - parentException: parentException, - stackTrace: stackTrace, - ), - const NoNetworkFailure() || - const ConnectionTimeoutFailure() || - const BadRequestFailure() || - const UnauthorizedFailure() || - const ForbiddenFailure() || - const NotFoundFailure() || - const ConflictFailure() || - const RateLimitedFailure() || - const ServerErrorFailure() || + NoNetworkFailure() || + ConnectionTimeoutFailure() || + BadRequestFailure() || + UnauthorizedFailure() || + ForbiddenFailure() || + NotFoundFailure() || + ConflictFailure() || + RateLimitedFailure() || + ServerErrorFailure() || UnknownNetworkFailure() => CardsRequestFailure( message, parentException: parentException,