diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ec747e..ed518a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +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. +- Profile current phase section for the authenticated `/profile` tab, rendering the read-only phase block alongside the aggregate statistics frequency summary. - 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. - Sign-up page now redirects users with unverified emails to the verify-email screen: shows a brief non-dismissible feedback dialog, then automatically pushes the verify-email route after 2 seconds, matching the existing sign-in behavior. @@ -41,7 +41,8 @@ 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. +- The `/profile` screen now sources each section independently from focused `/api/profile/*` endpoints (user, phase, user-parameters, history, active-subscription) instead of a single bootstrap payload: the user card, current phase, parameters, subscription, and statistics/history sections each load and refresh themselves, and charts switch between dedicated volume/frequency/trend endpoints. +- Removed the cross-feature `ProfileRefreshCubit` workaround now that the subscription section reloads itself after purchase or cancellation. - 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. diff --git a/docs/architecture.md b/docs/architecture.md index 1b696be9..62964c38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,8 @@ switch (result) { **Global singletons in DI** (alive for the entire app lifetime): - `AuthSessionCubit` — manages session state: `initial → checking → authenticated | unauthenticated | guestResumeAvailable | guest | guestCompletedOnboarding | restoreFailed`. - `NetworkCubit` — listens to `NetworkService` (connectivity_plus), emits `initial | connected | disconnected`. -- `ProfileRefreshCubit` — workaround: the shared `/profile` endpoint is used by multiple features; this cubit acts as a refresh signal without creating direct dependencies between features. + +After profile decomposition, each profile section cubit is self-contained and owns its own data. Page-level cubits call `load()` on creation via `..load()` in `BlocProvider`, so no cross-feature refresh signal is needed. All other cubits are created in `*_page_builder.dart` via `BlocProvider` and live as long as the widget tree. @@ -134,9 +135,8 @@ Registration order in `di.dart`: 5. TokenStorage, FitnessStartProgressStorage (Hive), CookieJar, GuestSessionStorage 6. Dio (AuthInterceptor + CookieManager + LoggingInterceptor) 7. ApiClients → Repositories (per feature) -8. **ProfileRefreshCubit** (singleton — workaround for profile refresh via shared endpoint) -9. **AuthSessionCubit** (singleton; depends on AuthRepository, TokenStorage, FitnessStartProgressStorage, GuestSessionStorage) -10. Tests, Workouts ApiClients → repositories +8. **AuthSessionCubit** (singleton; depends on AuthRepository, TokenStorage, FitnessStartProgressStorage, GuestSessionStorage) +9. Tests, Workouts ApiClients → repositories Page-level cubits are created in `*_page_builder.dart`: ```dart diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart index 115de91c..cdddff5a 100644 --- a/lib/core/di/di.dart +++ b/lib/core/di/di.dart @@ -29,7 +29,6 @@ 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/subscription_payment_api_client.dart'; import '../../features/subscriptions/data/remote/subscriptions_api_client.dart'; import '../../features/subscriptions/data/repositories/subscriptions_repository_impl.dart'; @@ -141,10 +140,6 @@ 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 856f0097..32005503 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -50,6 +50,21 @@ abstract class ApiPaths { /// The endpoint for uploading or deleting the authenticated user avatar. static const String profileAvatar = '$profile/avatar'; + /// The endpoint for focused authenticated user data (id, name, email, avatar). + static const String profileUser = '$profile/user'; + + /// The endpoint for the focused active subscription snapshot. + static const String profileActiveSubscription = '$profile/active-subscription'; + + /// The endpoint for the focused user parameters display snapshot. + static const String profileUserParameters = '$profile/user-parameters'; + + /// The endpoint for the focused profile history (subscriptions + workouts + tests). + static const String profileHistory = '$profile/history'; + + /// The endpoint for the focused current phase snapshot. + static const String profilePhase = '$profile/phase'; + /// The endpoint prefix for profile statistics. static const String profileStatistics = '$profile/statistics'; diff --git a/lib/features/profile/data/dto/focused/profile_active_subscription_response_dto.dart b/lib/features/profile/data/dto/focused/profile_active_subscription_response_dto.dart new file mode 100644 index 00000000..14c63682 --- /dev/null +++ b/lib/features/profile/data/dto/focused/profile_active_subscription_response_dto.dart @@ -0,0 +1,21 @@ +import '../active_profile_subscription_dto.dart'; + +/// DTO for the focused `/profile/active-subscription` response. +class ProfileActiveSubscriptionResponseDto { + /// Active subscription payload, or `null` when there is none. + final ActiveProfileSubscriptionDto? subscription; + + /// Creates an instance of [ProfileActiveSubscriptionResponseDto]. + const ProfileActiveSubscriptionResponseDto({required this.subscription}); + + /// Creates a [ProfileActiveSubscriptionResponseDto] from JSON. + factory ProfileActiveSubscriptionResponseDto.fromJson(Map json) { + final data = json['data']; + if (data is! Map || !data.containsKey('id')) { + return const ProfileActiveSubscriptionResponseDto(subscription: null); + } + return ProfileActiveSubscriptionResponseDto( + subscription: ActiveProfileSubscriptionDto.fromJson(data), + ); + } +} diff --git a/lib/features/profile/data/dto/focused/profile_history_response_dto.dart b/lib/features/profile/data/dto/focused/profile_history_response_dto.dart new file mode 100644 index 00000000..4ed2dc7c --- /dev/null +++ b/lib/features/profile/data/dto/focused/profile_history_response_dto.dart @@ -0,0 +1,39 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../profile_test_history_item_dto.dart'; +import '../profile_workout_history_item_dto.dart'; + +part 'profile_history_response_dto.g.dart'; + +/// DTO for the focused `/profile/history` response. +@JsonSerializable(createToJson: false) +class ProfileHistoryResponseDto { + /// History payload (always present, may contain empty arrays). + final ProfileHistoryDataDto data; + + /// Creates an instance of [ProfileHistoryResponseDto]. + ProfileHistoryResponseDto({required this.data}); + + /// Creates a [ProfileHistoryResponseDto] from JSON. + factory ProfileHistoryResponseDto.fromJson(Map json) => + _$ProfileHistoryResponseDtoFromJson(json); +} + +/// Payload of the focused `/profile/history` response. +@JsonSerializable(createToJson: false) +class ProfileHistoryDataDto { + /// Completed workouts history. + @JsonKey(defaultValue: []) + final List workouts; + + /// Completed test attempts history. + @JsonKey(defaultValue: []) + final List tests; + + /// Creates an instance of [ProfileHistoryDataDto]. + ProfileHistoryDataDto({required this.workouts, required this.tests}); + + /// Creates a [ProfileHistoryDataDto] from JSON. + factory ProfileHistoryDataDto.fromJson(Map json) => + _$ProfileHistoryDataDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/focused/profile_phase_response_dto.dart b/lib/features/profile/data/dto/focused/profile_phase_response_dto.dart new file mode 100644 index 00000000..9f493050 --- /dev/null +++ b/lib/features/profile/data/dto/focused/profile_phase_response_dto.dart @@ -0,0 +1,19 @@ +import '../profile_user_data_dto.dart'; + +/// DTO for the focused `/profile/phase` response. +class ProfilePhaseResponseDto { + /// Phase payload, or `null` when the user has no active phase. + final ProfilePhaseDto? phase; + + /// Creates an instance of [ProfilePhaseResponseDto]. + const ProfilePhaseResponseDto({required this.phase}); + + /// Creates a [ProfilePhaseResponseDto] from JSON. + factory ProfilePhaseResponseDto.fromJson(Map json) { + final data = json['data']; + if (data is! Map || !data.containsKey('has_progress')) { + return const ProfilePhaseResponseDto(phase: null); + } + return ProfilePhaseResponseDto(phase: ProfilePhaseDto.fromJson(data)); + } +} diff --git a/lib/features/profile/data/dto/focused/profile_user_only_response_dto.dart b/lib/features/profile/data/dto/focused/profile_user_only_response_dto.dart new file mode 100644 index 00000000..5af214e8 --- /dev/null +++ b/lib/features/profile/data/dto/focused/profile_user_only_response_dto.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../profile_user_dto.dart'; + +part 'profile_user_only_response_dto.g.dart'; + +/// DTO for the focused `/profile/user` response. +@JsonSerializable(createToJson: false) +class ProfileUserOnlyResponseDto { + /// Authenticated user payload. + final ProfileUserDto data; + + /// Creates an instance of [ProfileUserOnlyResponseDto]. + ProfileUserOnlyResponseDto({required this.data}); + + /// Creates a [ProfileUserOnlyResponseDto] from JSON. + factory ProfileUserOnlyResponseDto.fromJson(Map json) => + _$ProfileUserOnlyResponseDtoFromJson(json); +} diff --git a/lib/features/profile/data/dto/focused/profile_user_parameters_response_dto.dart b/lib/features/profile/data/dto/focused/profile_user_parameters_response_dto.dart new file mode 100644 index 00000000..0553cdff --- /dev/null +++ b/lib/features/profile/data/dto/focused/profile_user_parameters_response_dto.dart @@ -0,0 +1,21 @@ +import '../profile_user_data_dto.dart'; + +/// DTO for the focused `/profile/user-parameters` response. +class ProfileUserParametersResponseDto { + /// Parameters payload, or `null` when the user has no parameters yet. + final ProfileParametersInProfileDto? parameters; + + /// Creates an instance of [ProfileUserParametersResponseDto]. + const ProfileUserParametersResponseDto({required this.parameters}); + + /// Creates a [ProfileUserParametersResponseDto] from JSON. + factory ProfileUserParametersResponseDto.fromJson(Map json) { + final data = json['data']; + if (data is! Map || !data.containsKey('goal')) { + return const ProfileUserParametersResponseDto(parameters: null); + } + return ProfileUserParametersResponseDto( + parameters: ProfileParametersInProfileDto.fromJson(data), + ); + } +} diff --git a/lib/features/profile/data/mappers/profile_active_subscription_response_mapper.dart b/lib/features/profile/data/mappers/profile_active_subscription_response_mapper.dart new file mode 100644 index 00000000..cc976475 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_active_subscription_response_mapper.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/profile_stats_history_snapshot.dart'; +import '../dto/active_profile_subscription_dto.dart'; +import '../dto/focused/profile_active_subscription_response_dto.dart'; + +/// Maps `/profile/active-subscription` DTO to the active subscription snapshot. +extension ProfileActiveSubscriptionResponseMapper on ProfileActiveSubscriptionResponseDto { + /// Returns a snapshot of the active subscription, or `null` when there is none. + ProfileActiveSubscriptionSnapshot? toSnapshot() => subscription?.toEntity(); +} + +extension on ActiveProfileSubscriptionDto { + ProfileActiveSubscriptionSnapshot toEntity() => ProfileActiveSubscriptionSnapshot( + id: id, + name: name, + price: price, + startDate: startDate, + endDate: endDate, + ); +} diff --git a/lib/features/profile/data/mappers/profile_history_response_mapper.dart b/lib/features/profile/data/mappers/profile_history_response_mapper.dart new file mode 100644 index 00000000..d1cb9ac8 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_history_response_mapper.dart @@ -0,0 +1,43 @@ +import '../../domain/entities/profile_stats_history_snapshot.dart'; +import '../dto/focused/profile_history_response_dto.dart'; +import '../dto/profile_test_history_item_dto.dart'; +import '../dto/profile_workout_history_item_dto.dart'; + +/// Maps `/profile/history` DTO to the focused stats history snapshot. +extension ProfileHistoryResponseMapper on ProfileHistoryResponseDto { + /// Returns the latest completed workout and testing snapshots. + ProfileStatsHistorySnapshot toStatsHistorySnapshot() { + final sortedWorkouts = [ + ...data.workouts, + ]..sort((left, right) => _parseDate(right.completedAt).compareTo(_parseDate(left.completedAt))); + final sortedTests = [ + ...data.tests, + ]..sort((left, right) => _parseDate(right.completedAt).compareTo(_parseDate(left.completedAt))); + + return ProfileStatsHistorySnapshot( + latestWorkout: sortedWorkouts.isEmpty ? null : sortedWorkouts.first.toEntity(), + latestTest: sortedTests.isEmpty ? null : sortedTests.first.toEntity(), + ); + } +} + +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_history_snapshot_mapper.dart b/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart index aca2241b..54a53166 100644 --- a/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart +++ b/lib/features/profile/data/mappers/profile_history_snapshot_mapper.dart @@ -1,5 +1,4 @@ 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'; @@ -17,23 +16,12 @@ extension ProfileHistorySnapshotMapper on ProfileUserDataDto { ]..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, diff --git a/lib/features/profile/data/mappers/profile_phase_response_mapper.dart b/lib/features/profile/data/mappers/profile_phase_response_mapper.dart new file mode 100644 index 00000000..7c5d4915 --- /dev/null +++ b/lib/features/profile/data/mappers/profile_phase_response_mapper.dart @@ -0,0 +1,11 @@ +import '../../domain/entities/profile_phase_snapshot.dart'; +import '../dto/focused/profile_phase_response_dto.dart'; + +/// Maps `/profile/phase` DTO to phase snapshot entity. +extension ProfilePhaseResponseMapper on ProfilePhaseResponseDto { + /// 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/remote/profile_api_client.dart b/lib/features/profile/data/remote/profile_api_client.dart index 5103d880..b7528fdb 100644 --- a/lib/features/profile/data/remote/profile_api_client.dart +++ b/lib/features/profile/data/remote/profile_api_client.dart @@ -3,6 +3,11 @@ import 'package:retrofit/retrofit.dart'; import '../../../../core/network/api_paths.dart'; import '../dto/change_password_request_dto.dart'; +import '../dto/focused/profile_active_subscription_response_dto.dart'; +import '../dto/focused/profile_history_response_dto.dart'; +import '../dto/focused/profile_phase_response_dto.dart'; +import '../dto/focused/profile_user_only_response_dto.dart'; +import '../dto/focused/profile_user_parameters_response_dto.dart'; import '../dto/profile_user_response_dto.dart'; import '../dto/update_profile_request_dto.dart'; @@ -14,10 +19,30 @@ abstract class ProfileApiClient { /// Creates an instance of [ProfileApiClient]. factory ProfileApiClient(Dio dio, {String? baseUrl}) = _ProfileApiClient; - /// Returns the authenticated profile payload. + /// Returns the aggregate authenticated profile payload (legacy monolithic endpoint). @GET(ApiPaths.profile) Future getProfile(); + /// Returns the focused authenticated user payload (id, name, email, avatar). + @GET(ApiPaths.profileUser) + Future getUser(); + + /// Returns the focused active subscription snapshot. + @GET(ApiPaths.profileActiveSubscription) + Future getActiveSubscription(); + + /// Returns the focused current phase snapshot. + @GET(ApiPaths.profilePhase) + Future getPhase(); + + /// Returns the focused user parameters display snapshot. + @GET(ApiPaths.profileUserParameters) + Future getProfileUserParameters(); + + /// Returns the focused profile history payload (workouts + tests arrays). + @GET(ApiPaths.profileHistory) + Future getHistory(); + /// Updates the authenticated user profile fields. @PUT(ApiPaths.profile) Future updateProfile(@Body() UpdateProfileRequestDto request); diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index c1cf971c..4ebcedd3 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -8,15 +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_active_subscription_response_mapper.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_history_response_mapper.dart'; +import '../mappers/profile_phase_response_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -24,10 +23,6 @@ import '../remote/profile_api_client.dart'; final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; - ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; - ProfilePhaseSnapshot? _cachedPhaseSnapshot; - ProfileParametersSnapshot? _cachedParametersSnapshot; - bool _hasCachedParametersSnapshot = false; /// Creates an instance of [ProfileRepositoryImpl]. ProfileRepositoryImpl(this._logger, this._apiClient); @@ -35,12 +30,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { @override Future> getUser() async { try { - 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()); + final response = await _apiClient.getUser(); + return Result.success(response.data.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); return Result.failure(networkFailure.toProfileFailure()); @@ -54,19 +45,9 @@ 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; - _cachedPhaseSnapshot = response.data.toPhaseSnapshot(); - _cachedParametersSnapshot = response.data.toParametersSnapshot(); - _hasCachedParametersSnapshot = true; - return Result.success(snapshot); + final response = await _apiClient.getHistory(); + return Result.success(response.toStatsHistorySnapshot()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); return Result.failure(networkFailure.toProfileFailure()); @@ -80,19 +61,9 @@ 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; - _cachedParametersSnapshot = response.data.toParametersSnapshot(); - _hasCachedParametersSnapshot = true; - return Result.success(snapshot); + final response = await _apiClient.getPhase(); + return Result.success(response.toPhaseSnapshot()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); return Result.failure(networkFailure.toProfileFailure()); @@ -105,24 +76,15 @@ final class ProfileRepositoryImpl implements ProfileRepository { } @override - Future> getParametersSnapshot() async { - if (_hasCachedParametersSnapshot) { - return Result.success(_cachedParametersSnapshot); - } - + Future> getActiveSubscription() async { 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); + final response = await _apiClient.getActiveSubscription(); + return Result.success(response.toSnapshot()); } 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); + _logger.e('GetActiveSubscription failed with unexpected error', e, s); return Result.failure( UnknownProfileFailure(parentException: e, stackTrace: s), ); @@ -164,12 +126,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { await _apiClient.updateProfile(request); } - 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()); + final refreshedResponse = await _apiClient.getUser(); + return Result.success(refreshedResponse.data.toEntity()); } on DioException catch (e) { final networkFailure = e.toNetworkFailure(); return Result.failure(networkFailure.toProfileFailure()); diff --git a/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart b/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart index 9a5d95e0..46f98592 100644 --- a/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart +++ b/lib/features/profile/domain/entities/profile_stats_history_snapshot.dart @@ -2,9 +2,6 @@ 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; @@ -13,13 +10,12 @@ final class ProfileStatsHistorySnapshot extends Equatable { /// Creates an instance of [ProfileStatsHistorySnapshot]. const ProfileStatsHistorySnapshot({ - required this.activeSubscription, required this.latestWorkout, required this.latestTest, }); @override - List get props => [activeSubscription, latestWorkout, latestTest]; + List get props => [latestWorkout, latestTest]; } /// Active subscription content used in the history dialog. diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index 345c1450..3e10cc5b 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -2,7 +2,6 @@ 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. @@ -16,8 +15,8 @@ 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(); + /// Returns the current active subscription snapshot for the subscription section. + Future> getActiveSubscription(); /// Updates the current user profile and returns the canonical refreshed user payload. Future> updateUser({ diff --git a/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart b/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart index 156aa6b9..31c83fee 100644 --- a/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart @@ -6,7 +6,6 @@ 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'; @@ -20,18 +19,6 @@ final class ProfileParametersCubit extends Cubit { /// 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); @@ -145,7 +132,6 @@ final class ProfileParametersCubit extends Cubit { isSubmitting: false, shouldReloadWorkouts: shouldReloadWorkouts, currentParameters: data, - bootstrapSnapshot: _toSnapshot(data), selectedGoalId: data.goalId, selectedGender: data.gender, selectedEquipmentId: data.equipmentId, @@ -202,9 +188,6 @@ final class ProfileParametersCubit extends Cubit { 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, @@ -213,14 +196,4 @@ final class ProfileParametersCubit extends Cubit { ), ); } - - 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 index 6532b78e..40eb6467 100644 --- a/lib/features/profile/presentation/cubits/profile_parameters_state.dart +++ b/lib/features/profile/presentation/cubits/profile_parameters_state.dart @@ -8,7 +8,6 @@ abstract class ProfileParametersState with _$ProfileParametersState { @Default(false) bool isLoading, @Default(false) bool isSubmitting, @Default(false) bool shouldReloadWorkouts, - ProfileParametersSnapshot? bootstrapSnapshot, ProfileParametersReferences? references, ProfileParametersData? currentParameters, int? selectedGoalId, diff --git a/lib/features/profile/presentation/cubits/profile_phase_cubit.dart b/lib/features/profile/presentation/cubits/profile_phase_cubit.dart new file mode 100644 index 00000000..51572a99 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_phase_cubit.dart @@ -0,0 +1,106 @@ +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_phase_snapshot.dart'; +import '../../domain/entities/profile_statistics/profile_current_phase_summary.dart'; +import '../../domain/repositories/profile_repository.dart'; +import '../../domain/repositories/profile_statistics_repository.dart'; + +part 'profile_phase_cubit.freezed.dart'; +part 'profile_phase_state.dart'; + +/// Cubit that manages the profile current phase section payload. +final class ProfilePhaseCubit extends Cubit { + final ProfileRepository _profileRepository; + final ProfileStatisticsRepository _statisticsRepository; + + /// Creates an instance of [ProfilePhaseCubit]. + ProfilePhaseCubit( + this._profileRepository, + this._statisticsRepository, + ) : super(const ProfilePhaseState()); + + /// Loads the phase snapshot together with the current phase summary. + Future load() async { + if (state.isLoading) return; + + emit( + state.copyWith( + isLoading: true, + isLoadingSummary: true, + phaseFailure: null, + summaryFailure: null, + ), + ); + + final phaseFuture = _profileRepository.getPhaseSnapshot(); + final summaryFuture = _statisticsRepository.getCurrentPhaseSummary(); + + final phaseResult = await phaseFuture; + final summaryResult = await summaryFuture; + if (isClosed) return; + + final phaseSnapshot = switch (phaseResult) { + Success(:final data) => data, + Failure() => state.phaseSnapshot, + }; + final phaseFailure = switch (phaseResult) { + Success() => null, + Failure(:final error) => error, + }; + final currentPhaseSummary = switch (summaryResult) { + Success(:final data) => data, + Failure() => state.currentPhaseSummary, + }; + final summaryFailure = switch (summaryResult) { + Success() => null, + Failure(:final error) => error, + }; + + emit( + state.copyWith( + isLoading: false, + isLoadingSummary: false, + phaseSnapshot: phaseSnapshot, + currentPhaseSummary: currentPhaseSummary, + phaseFailure: phaseFailure, + summaryFailure: summaryFailure, + ), + ); + } + + /// Reloads only the current phase summary data. + Future reloadSummary() async { + if (state.isLoadingSummary) return; + + emit( + state.copyWith( + isLoadingSummary: true, + summaryFailure: null, + ), + ); + + final result = await _statisticsRepository.getCurrentPhaseSummary(); + if (isClosed) return; + + switch (result) { + case Success(:final data): + emit( + state.copyWith( + isLoadingSummary: false, + currentPhaseSummary: data, + summaryFailure: null, + ), + ); + case Failure(:final error): + emit( + state.copyWith( + isLoadingSummary: false, + summaryFailure: error, + ), + ); + } + } +} diff --git a/lib/features/profile/presentation/cubits/profile_phase_state.dart b/lib/features/profile/presentation/cubits/profile_phase_state.dart new file mode 100644 index 00000000..69880d97 --- /dev/null +++ b/lib/features/profile/presentation/cubits/profile_phase_state.dart @@ -0,0 +1,15 @@ +part of 'profile_phase_cubit.dart'; + +/// State for [ProfilePhaseCubit]. +@freezed +abstract class ProfilePhaseState with _$ProfilePhaseState { + /// Creates an instance of [ProfilePhaseState]. + const factory ProfilePhaseState({ + @Default(false) bool isLoading, + @Default(false) bool isLoadingSummary, + ProfilePhaseSnapshot? phaseSnapshot, + ProfileCurrentPhaseSummary? currentPhaseSummary, + ProfileFailure? phaseFailure, + ProfileFailure? summaryFailure, + }) = _ProfilePhaseState; +} diff --git a/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart b/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart deleted file mode 100644 index 927a8488..00000000 --- a/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 1e3b6f3e..00000000 --- a/lib/features/profile/presentation/cubits/profile_refresh_state.dart +++ /dev/null @@ -1,10 +0,0 @@ -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_statistics_cubit.dart b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart index 4efe207c..8eaa4b39 100644 --- a/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart @@ -5,7 +5,6 @@ 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'; @@ -13,6 +12,7 @@ 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_repository.dart'; import '../../domain/repositories/profile_statistics_repository.dart'; part 'profile_statistics_cubit.freezed.dart'; @@ -21,40 +21,33 @@ part 'profile_statistics_state.dart'; /// Cubit that manages the profile statistics state flow. final class ProfileStatisticsCubit extends Cubit { final ProfileStatisticsRepository _repository; + final ProfileRepository _profileRepository; /// Creates an instance of [ProfileStatisticsCubit]. - ProfileStatisticsCubit(this._repository) : super(const ProfileStatisticsState()); + ProfileStatisticsCubit( + this._repository, + this._profileRepository, + ) : super(const ProfileStatisticsState()); /// Loads the initial statistics payload. Future loadInitial() async { if (state.isLoading) return; - emit( - state.copyWith( - isLoading: true, - isLoadingCurrentPhaseSummary: true, - failure: null, - currentPhaseSummaryFailure: null, - ), - ); + emit(state.copyWith(isLoading: true, failure: null)); final volumeFuture = _repository.getVolume(); final exercisesFuture = _repository.getExercises(); - final currentPhaseSummaryFuture = _repository.getCurrentPhaseSummary(); + final historyFuture = _profileRepository.getStatsHistorySnapshot(); final volumeResult = await volumeFuture; final exercisesResult = await exercisesFuture; - final currentPhaseSummaryResult = await currentPhaseSummaryFuture; + final historyResult = await historyFuture; 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, + final historySnapshot = switch (historyResult) { + Success(:final data) => data, + Failure() => state.historySnapshot, }; switch (volumeResult) { @@ -66,13 +59,11 @@ 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, + historySnapshot: historySnapshot, failure: null, ), ); @@ -80,22 +71,13 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, - isLoadingCurrentPhaseSummary: false, - currentPhaseSummary: currentPhaseSummary, - currentPhaseSummaryFailure: currentPhaseSummaryFailure, + historySnapshot: historySnapshot, 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; @@ -231,39 +213,6 @@ 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 7bd7a07c..f91370cc 100644 --- a/lib/features/profile/presentation/cubits/profile_statistics_state.dart +++ b/lib/features/profile/presentation/cubits/profile_statistics_state.dart @@ -6,7 +6,6 @@ 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, @@ -14,13 +13,11 @@ 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_subscription_cubit.dart b/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart index cc6544e9..1864ff9c 100644 --- a/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart @@ -1,93 +1,82 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../../../../core/failures/app_failure.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'; +import '../../domain/repositories/profile_repository.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. +/// `/profile/active-subscription` 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; + final ProfileRepository _profileRepository; + final SubscriptionsRepository _subscriptionsRepository; /// Creates an instance of [ProfileSubscriptionCubit]. - ProfileSubscriptionCubit(this._repository) : super(const ProfileSubscriptionState()); + ProfileSubscriptionCubit( + this._profileRepository, + this._subscriptionsRepository, + ) : 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; - } + /// Loads the active subscription snapshot and matching catalog card. + Future load() async { + if (state.isLoading) 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, - ), - ); + final activeResult = await _profileRepository.getActiveSubscription(); + if (isClosed) return; - await _loadSubscription(); + switch (activeResult) { + case Failure(:final error): + emit( + state.copyWith( + isLoading: false, + activeSubscription: null, + item: null, + failure: error, + ), + ); + case Success(:final data): + if (data == null) { + emit(const ProfileSubscriptionState()); + return; + } + await _loadCatalogItem(data); + } } - Future _loadSubscription() async { - final activeSubscription = state.activeSubscription; - if (activeSubscription == null) { - emit(const ProfileSubscriptionState()); - return; - } + /// Retries loading the active subscription card data. + Future retry() => load(); - final result = await _repository.getSubscriptions(); + Future _loadCatalogItem( + ProfileActiveSubscriptionSnapshot activeSubscription, + ) async { + final result = await _subscriptionsRepository.getSubscriptions(); if (isClosed) return; switch (result) { case Success(:final data): - final item = _findMatchingItem( - data, - activeSubscription: activeSubscription, - ); + final item = _findMatchingItem(data, activeSubscription: activeSubscription); if (item == null) { emit( state.copyWith( isLoading: false, + activeSubscription: activeSubscription, item: null, failure: const SubscriptionsNotFoundFailure(), ), @@ -97,6 +86,7 @@ final class ProfileSubscriptionCubit extends Cubit { emit( state.copyWith( isLoading: false, + activeSubscription: activeSubscription, item: item, failure: null, ), @@ -105,6 +95,7 @@ final class ProfileSubscriptionCubit extends Cubit { emit( state.copyWith( isLoading: false, + activeSubscription: activeSubscription, item: null, failure: error, ), diff --git a/lib/features/profile/presentation/cubits/profile_subscription_state.dart b/lib/features/profile/presentation/cubits/profile_subscription_state.dart index 363421a6..db00cbd1 100644 --- a/lib/features/profile/presentation/cubits/profile_subscription_state.dart +++ b/lib/features/profile/presentation/cubits/profile_subscription_state.dart @@ -8,6 +8,6 @@ abstract class ProfileSubscriptionState with _$ProfileSubscriptionState { @Default(false) bool isLoading, ProfileActiveSubscriptionSnapshot? activeSubscription, SubscriptionCatalogItem? item, - SubscriptionsFailure? failure, + AppFailure? failure, }) = _ProfileSubscriptionState; } diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart index be490315..745c52fe 100644 --- a/lib/features/profile/presentation/cubits/profile_user_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart @@ -4,9 +4,6 @@ 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_parameters/profile_parameters_snapshot.dart'; -import '../../domain/entities/profile_stats_history_snapshot.dart'; import '../../domain/repositories/profile_repository.dart'; part 'profile_user_cubit.freezed.dart'; @@ -38,32 +35,10 @@ final class ProfileUserCubit extends Cubit { switch (result) { case Success(data: final user): - final historyResult = await _repository.getStatsHistorySnapshot(); - 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, - Failure() => state.historySnapshot, - }; - final phaseSnapshot = switch (phaseResult) { - 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 8b2c6505..a070f8b8 100644 --- a/lib/features/profile/presentation/cubits/profile_user_state.dart +++ b/lib/features/profile/presentation/cubits/profile_user_state.dart @@ -7,9 +7,6 @@ abstract class ProfileUserState with _$ProfileUserState { const factory ProfileUserState({ @Default(false) bool isLoading, 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 2245c472..411e354b 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -14,9 +14,6 @@ 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_refresh_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'; @@ -74,83 +71,56 @@ class ProfilePage extends StatelessWidget { ), ], ), - 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); - }, - ), - ], - 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), - ), - const SizedBox(height: 36), - ProfileSubscriptionSectionWidget( - activeSubscription: state.historySnapshot?.activeSubscription, - ), - const SizedBox(height: 36), - const ProfileCardsSectionWidget(), - const SizedBox(height: 36), - const CurrentPhaseSectionWidget(), - const SizedBox(height: 36), - const ProfileParametersSectionWidget(), - const SizedBox(height: 36), - const ProfileBottomSectionWidget(), - ], - ), + 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), + ), + const SizedBox(height: 36), + const StatsSectionWidget(), + const SizedBox(height: 20), + SecondaryButton( + onPressed: () => _openHistoryDialog(context), + child: const Text(AppStrings.profileStatsHistoryButton), + ), + const SizedBox(height: 36), + const ProfileSubscriptionSectionWidget(), + const SizedBox(height: 36), + const ProfileCardsSectionWidget(), + const SizedBox(height: 36), + const CurrentPhaseSectionWidget(), + const SizedBox(height: 36), + const ProfileParametersSectionWidget(), + const SizedBox(height: 36), + const ProfileBottomSectionWidget(), + ], + ), + ); + }, ), ); } diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 8092c1e3..fd73ef6f 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -20,7 +20,7 @@ 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_phase_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_user_cubit.dart'; @@ -50,8 +50,15 @@ class ProfilePageBuilder extends StatelessWidget { BlocProvider( create: (_) => ProfileStatisticsCubit( di(), + di(), )..loadInitial(), ), + BlocProvider( + create: (_) => ProfilePhaseCubit( + di(), + di(), + )..load(), + ), BlocProvider( create: (_) => ProfileParametersCubit( di(), @@ -59,8 +66,9 @@ class ProfilePageBuilder extends StatelessWidget { ), BlocProvider( create: (_) => ProfileSubscriptionCubit( + di(), di(), - ), + )..load(), ), BlocProvider( create: (_) => CardsCubit( @@ -76,9 +84,6 @@ class ProfilePageBuilder extends StatelessWidget { BlocProvider( create: (_) => DeleteCardCubit(di()), ), - BlocProvider.value( - value: di(), - ), BlocProvider( create: (_) => LogoutCubit(di()), ), diff --git a/lib/features/profile/presentation/widgets/current_phase_section_widget.dart b/lib/features/profile/presentation/widgets/current_phase_section_widget.dart index 773fbfc9..c0965ccd 100644 --- a/lib/features/profile/presentation/widgets/current_phase_section_widget.dart +++ b/lib/features/profile/presentation/widgets/current_phase_section_widget.dart @@ -6,8 +6,7 @@ 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'; +import '../cubits/profile_phase_cubit.dart'; /// Read-only profile section with the current phase name and summary numbers. class CurrentPhaseSectionWidget extends StatelessWidget { @@ -16,16 +15,13 @@ class CurrentPhaseSectionWidget extends StatelessWidget { @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; + return BlocBuilder( + builder: (context, state) { + final phaseSnapshot = state.phaseSnapshot; if (phaseSnapshot == null) { return _CurrentPhaseErrorState( - onRetryPressed: () => context.read().refresh(), - isLoading: userState.isLoading, + onRetryPressed: () => context.read().load(), + isLoading: state.isLoading, ); } @@ -35,34 +31,25 @@ class CurrentPhaseSectionWidget extends StatelessWidget { ); } - 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( + final summary = state.currentPhaseSummary; + if (summary == null) { + if (state.isLoadingSummary) { + return _CurrentPhaseLoadingState( currentPhaseName: phaseSnapshot.currentPhaseName!, - averagePerWeek: currentPhaseSummary.averagePerWeek.round(), - weeklyGoal: '${currentPhaseSummary.weeklyGoal}', ); - }, + } + + return _CurrentPhaseErrorState( + currentPhaseName: phaseSnapshot.currentPhaseName, + onRetryPressed: () => context.read().reloadSummary(), + isLoading: false, + ); + } + + return _CurrentPhaseContent( + currentPhaseName: phaseSnapshot.currentPhaseName!, + averagePerWeek: summary.averagePerWeek.round(), + weeklyGoal: '${summary.weeklyGoal}', ); }, ); diff --git a/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart b/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart index 1f9c4f99..4054ef1b 100644 --- a/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_parameters_section_widget.dart @@ -24,7 +24,7 @@ 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_phase_cubit.dart'; import '../cubits/profile_user_cubit.dart'; enum _ProfileParametersDropdown { @@ -204,15 +204,14 @@ class _ProfileParametersSectionWidgetState extends State( + final currentWeeklyGoal = context.select( (cubit) => cubit.state.currentPhaseSummary?.weeklyGoal, ); - final isLoadingWeeklyGoal = context.select( - (cubit) => cubit.state.isLoadingCurrentPhaseSummary, + final isLoadingWeeklyGoal = context.select( + (cubit) => cubit.state.isLoadingSummary, ); - final currentWeeklyGoalFailure = context.select( - (cubit) => - cubit.state.currentPhaseSummary == null && cubit.state.currentPhaseSummaryFailure != null, + final currentWeeklyGoalFailure = context.select( + (cubit) => cubit.state.currentPhaseSummary == null && cubit.state.summaryFailure != null, ); return BlocConsumer( @@ -235,7 +234,7 @@ class _ProfileParametersSectionWidgetState extends State().loadWorkouts()); context.read().consumeWorkoutsReloadRequest(); } - unawaited(context.read().reloadCurrentPhaseSummary()); + unawaited(context.read().reloadSummary()); unawaited(context.read().refresh()); } }, @@ -265,7 +264,7 @@ class _ProfileParametersSectionWidgetState extends State().reload(); - context.read().reloadCurrentPhaseSummary(); + context.read().reloadSummary(); }, ), ); diff --git a/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart b/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart index 07a2d6b1..351fd02a 100644 --- a/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_subscription_section_widget.dart @@ -17,19 +17,12 @@ 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, - }); + const ProfileSubscriptionSectionWidget({super.key}); @override State createState() => _ProfileSubscriptionSectionWidgetState(); @@ -38,25 +31,6 @@ class ProfileSubscriptionSectionWidget extends StatefulWidget { 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)); } @@ -128,14 +102,12 @@ class _ProfileSubscriptionSectionWidgetState extends State( listener: (context, state) { state.whenOrNull( succeed: () { _closeActiveDialog(); - context.read().requestRefresh(); + unawaited(context.read().load()); }, failed: (failure) { _closeActiveDialog(); @@ -152,6 +124,7 @@ class _ProfileSubscriptionSectionWidgetState extends State( builder: (context, state) { + final activeSubscription = state.activeSubscription; if (activeSubscription == null) { return _ProfileSubscriptionEmptyState( onPressed: _openCatalog, diff --git a/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart b/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart index 5fa366a1..f9f71131 100644 --- a/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart +++ b/lib/features/profile/presentation/widgets/stats/profile_history_dialog.dart @@ -10,20 +10,25 @@ 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 '../../cubits/profile_subscription_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); + final statisticsCubit = context.read(); + final subscriptionCubit = context.read(); + statisticsCubit.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: MultiBlocProvider( + providers: [ + BlocProvider.value(value: statisticsCubit), + BlocProvider.value(value: subscriptionCubit), + ], child: const ProfileHistoryDialog(), ), ); @@ -150,7 +155,7 @@ final class _HistoryContent extends StatelessWidget { @override Widget build(BuildContext context) { final content = switch (selectedTab) { - ProfileHistoryTab.subscriptions => _buildSubscriptionContent(), + ProfileHistoryTab.subscriptions => const _HistorySubscriptionContent(), ProfileHistoryTab.workouts => _buildWorkoutContent(), ProfileHistoryTab.tests => _buildTestContent(), }; @@ -158,32 +163,6 @@ final class _HistoryContent extends StatelessWidget { 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) { @@ -229,6 +208,41 @@ final class _HistoryContent extends StatelessWidget { } } +final class _HistorySubscriptionContent extends StatelessWidget { + const _HistorySubscriptionContent(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final subscription = state.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)}', + ), + ], + ); + }, + ); + } +} + final class _HistoryValueList extends StatelessWidget { final List<_HistoryValueItem> items; diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart index 1e54225b..0d3f3e86 100644 --- a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart +++ b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart @@ -6,7 +6,6 @@ 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'; @@ -14,7 +13,6 @@ 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'; @@ -50,7 +48,6 @@ class SubscriptionsDetailsPage extends StatelessWidget { paymentCubit: context.read(), ); if (!context.mounted || didPay != true) return; - di().requestRefresh(); context.go(AppRoutePaths.profilePath); } 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 5e83eed1..f3a99c59 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -7,11 +7,12 @@ import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dar 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/focused/profile_history_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/focused/profile_phase_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/focused/profile_user_only_response_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/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'; @@ -38,8 +39,8 @@ void main() { group('getUser', () { test('returns success(user) when api succeeds', () async { // Arrange - final responseDto = createProfileUserResponseDto(); - when(apiClient.getProfile()).thenAnswer((_) async => responseDto); + final responseDto = ProfileUserOnlyResponseDto(data: createProfileUserDto()); + when(apiClient.getUser()).thenAnswer((_) async => responseDto); // Act final result = await repository.getUser(); @@ -48,18 +49,18 @@ void main() { expect(result.isSuccess, isTrue); expect(result.success, createProfileUser()); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns ProfileRequestFailure when api returns server error', () async { // Arrange final exception = createProfileDioBadResponseException( - path: '/api/profile', + path: '/api/profile/user', statusCode: 500, code: 'server_error', ); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getUser()).thenThrow(exception); // Act final result = await repository.getUser(); @@ -69,14 +70,14 @@ void main() { expect(result.failure, isA()); expect(result.failure!.parentException, exception); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns UnknownProfileFailure when unexpected exception occurs', () async { // Arrange final exception = Exception('unexpected_error'); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getUser()).thenThrow(exception); // Act final result = await repository.getUser(); @@ -86,7 +87,7 @@ void main() { expect(result.failure, isA()); expect(result.failure!.parentException, exception); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verify(logger.e(any, exception, any)).called(1); verifyNoMoreInteractions(apiClient); }); @@ -112,7 +113,7 @@ void main() { // Assert expect(result.isSuccess, isTrue); expect(result.success, currentUser); - verifyNever(apiClient.getProfile()); + verifyNever(apiClient.getUser()); verifyNever(apiClient.updateProfile(any)); verifyNever(apiClient.uploadAvatar(any)); verifyNever(apiClient.changePassword(any)); @@ -132,8 +133,8 @@ void main() { ); when(apiClient.updateProfile(any)).thenAnswer((_) async {}); when( - apiClient.getProfile(), - ).thenAnswer((_) async => createProfileUserResponseDto(user: refreshedUser)); + apiClient.getUser(), + ).thenAnswer((_) async => ProfileUserOnlyResponseDto(data: refreshedUser)); // Act final result = await repository.updateUser( @@ -157,7 +158,7 @@ void main() { expect(captured.name, 'test_name'); expect(captured.email, 'test@mail.com'); verifyNever(apiClient.uploadAvatar(any)); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verifyNoMoreInteractions(apiClient); }); @@ -172,9 +173,9 @@ void main() { 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'), + when(apiClient.getUser()).thenAnswer( + (_) async => ProfileUserOnlyResponseDto( + data: createProfileUserDto(avatarUrl: 'new-avatar.jpg'), ), ); @@ -196,7 +197,7 @@ void main() { verify(apiClient.uploadAvatar(any)).called(1); verifyNever(apiClient.updateProfile(any)); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verifyNoMoreInteractions(apiClient); }); @@ -212,9 +213,9 @@ void main() { await avatarFile.writeAsString('avatar'); when(apiClient.uploadAvatar(any)).thenAnswer((_) async {}); when(apiClient.updateProfile(any)).thenAnswer((_) async {}); - when(apiClient.getProfile()).thenAnswer( - (_) async => createProfileUserResponseDto( - user: createProfileUserDto( + when(apiClient.getUser()).thenAnswer( + (_) async => ProfileUserOnlyResponseDto( + data: createProfileUserDto( name: 'test_name', email: 'test@mail.com', avatarUrl: 'new-avatar.jpg', @@ -247,70 +248,38 @@ void main() { verify(apiClient.uploadAvatar(any)).called(1); verify(apiClient.updateProfile(any)).called(1); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getUser()).called(1); verifyNoMoreInteractions(apiClient); }); }); 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 { + test('returns latest sorted workout and test from /profile/history', () 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', - ), - ], - ), + when(apiClient.getHistory()).thenAnswer( + (_) async => createProfileHistoryResponseDto( + workouts: [ + createProfileWorkoutHistoryItemDto( + id: 1, + title: 'older workout', + completedAt: '2026-03-10 10:30:00', + ), + createProfileWorkoutHistoryItemDto( + id: 2, + title: 'latest workout', + ), + ], + tests: [ + createProfileTestHistoryItemDto( + attemptId: 1, + title: 'older test', + completedAt: '2026-03-12 15:20:00', + ), + createProfileTestHistoryItemDto( + attemptId: 2, + title: 'latest test', + ), + ], ), ); @@ -322,13 +291,6 @@ void main() { expect( result.success, const ProfileStatsHistorySnapshot( - activeSubscription: ProfileActiveSubscriptionSnapshot( - id: testProfileSubscriptionId, - name: testProfileSubscriptionName, - price: testProfileSubscriptionPrice, - startDate: testProfileSubscriptionStartDate, - endDate: testProfileSubscriptionEndDate, - ), latestWorkout: ProfileLatestWorkoutSnapshot( id: 2, title: 'latest workout', @@ -342,42 +304,40 @@ void main() { ), ); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getHistory()).called(1); verifyNoMoreInteractions(apiClient); }); - test('warms parameters cache from the same /profile response', () async { + test('returns empty snapshot when backend returns no history', () async { // Arrange - when(apiClient.getProfile()).thenAnswer( - (_) async => createProfileUserResponseDto( - subscriptions: createProfileSubscriptionsDto(), - workouts: createProfileWorkoutsDto(), - tests: createProfileTestsDto(), - parameters: createProfileParametersInProfileDto(), + when(apiClient.getHistory()).thenAnswer( + (_) async => ProfileHistoryResponseDto( + data: ProfileHistoryDataDto(workouts: const [], tests: const []), ), ); // Act - final historyResult = await repository.getStatsHistorySnapshot(); - final parametersResult = await repository.getParametersSnapshot(); + final result = await repository.getStatsHistorySnapshot(); // Assert - expect(historyResult.isSuccess, isTrue); - expect(parametersResult.isSuccess, isTrue); - expect(parametersResult.success, createProfileParametersSnapshot()); + expect(result.isSuccess, isTrue); + expect( + result.success, + const ProfileStatsHistorySnapshot(latestWorkout: null, latestTest: null), + ); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getHistory()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns ProfileRequestFailure when api returns server error', () async { // Arrange final exception = createProfileDioBadResponseException( - path: '/api/profile', + path: '/api/profile/history', statusCode: 500, code: 'server_error', ); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getHistory()).thenThrow(exception); // Act final result = await repository.getStatsHistorySnapshot(); @@ -387,14 +347,14 @@ void main() { expect(result.failure, isA()); expect(result.failure!.parentException, exception); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getHistory()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns UnknownProfileFailure when unexpected exception occurs', () async { // Arrange final exception = Exception('unexpected_error'); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getHistory()).thenThrow(exception); // Act final result = await repository.getStatsHistorySnapshot(); @@ -404,42 +364,17 @@ void main() { expect(result.failure, isA()); expect(result.failure!.parentException, exception); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getHistory()).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 { + test('returns phase snapshot from /profile/phase', () async { // Arrange - when( - apiClient.getProfile(), - ).thenAnswer( - (_) async => createProfileUserResponseDto( + when(apiClient.getPhase()).thenAnswer( + (_) async => createProfilePhaseResponseDto( phase: createProfilePhaseDto( currentPhase: createProfileCurrentPhaseDto( id: 12, @@ -462,169 +397,65 @@ void main() { ), ); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getPhase()).called(1); verifyNoMoreInteractions(apiClient); }); - test('returns ProfileRequestFailure when api returns server error', () async { + test('returns empty snapshot when backend returns no active phase', () async { // Arrange - final exception = createProfileDioBadResponseException( - path: '/api/profile', - statusCode: 500, - code: 'server_error', + when(apiClient.getPhase()).thenAnswer( + (_) async => const ProfilePhaseResponseDto(phase: null), ); - 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('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: 'Начинающий', - ), + const ProfilePhaseSnapshot(hasProgress: false, currentPhaseName: null), ); - 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); + verify(apiClient.getPhase()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns ProfileRequestFailure when api returns server error', () async { // Arrange final exception = createProfileDioBadResponseException( - path: '/api/profile', + path: '/api/profile/phase', statusCode: 500, code: 'server_error', ); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getPhase()).thenThrow(exception); // Act - final result = await repository.getParametersSnapshot(); + 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(apiClient.getPhase()).called(1); verifyNoMoreInteractions(apiClient); }); test('returns UnknownProfileFailure when unexpected exception occurs', () async { // Arrange final exception = Exception('unexpected_error'); - when(apiClient.getProfile()).thenThrow(exception); + when(apiClient.getPhase()).thenThrow(exception); // Act - final result = await repository.getParametersSnapshot(); + 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(apiClient.getPhase()).called(1); verify(logger.e(any, exception, any)).called(1); verifyNoMoreInteractions(apiClient); }); diff --git a/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart b/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart index 47142a34..ba220f9f 100644 --- a/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_parameters_cubit_test.dart @@ -7,11 +7,9 @@ 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'; @@ -52,15 +50,6 @@ void main() { 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, @@ -95,35 +84,6 @@ void main() { }, ); - 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, @@ -226,15 +186,6 @@ void main() { 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, @@ -307,15 +258,6 @@ void main() { 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, @@ -415,15 +357,6 @@ void main() { 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, diff --git a/test/features/profile/presentation/cubits/profile_phase_cubit_test.dart b/test/features/profile/presentation/cubits/profile_phase_cubit_test.dart new file mode 100644 index 00000000..f95763df --- /dev/null +++ b/test/features/profile/presentation/cubits/profile_phase_cubit_test.dart @@ -0,0 +1,182 @@ +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_phase_snapshot.dart'; +import 'package:moveup_flutter/features/profile/domain/entities/profile_statistics/profile_current_phase_summary.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_statistics_repository.dart'; +import 'package:moveup_flutter/features/profile/presentation/cubits/profile_phase_cubit.dart'; + +import '../../support/profile_statistics_dto_fixtures.dart'; +import 'profile_phase_cubit_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockProfileRepository profileRepository; + late MockProfileStatisticsRepository statisticsRepository; + late ProfilePhaseCubit cubit; + const failure = ProfileRequestFailure('error_message'); + const phaseSnapshot = ProfilePhaseSnapshot( + hasProgress: true, + currentPhaseName: 'Накопление', + ); + + setUp(() { + profileRepository = MockProfileRepository(); + statisticsRepository = MockProfileStatisticsRepository(); + cubit = ProfilePhaseCubit(profileRepository, statisticsRepository); + provideDummy>( + const Success(phaseSnapshot), + ); + provideDummy>( + const Success(testProfileCurrentPhaseSummary), + ); + }); + + group('ProfilePhaseCubit', () { + blocTest( + 'load emits loading then loaded state when both requests succeed', + setUp: () { + when(profileRepository.getPhaseSnapshot()).thenAnswer( + (_) async => const Success(phaseSnapshot), + ); + when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ); + }, + build: () => cubit, + act: (cubit) => cubit.load(), + expect: () => const [ + ProfilePhaseState(isLoading: true, isLoadingSummary: true), + ProfilePhaseState( + phaseSnapshot: phaseSnapshot, + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + ], + verify: (_) { + verify(profileRepository.getPhaseSnapshot()).called(1); + verify(statisticsRepository.getCurrentPhaseSummary()).called(1); + }, + ); + + blocTest( + 'load stores phase failure when phase request fails', + setUp: () { + when(profileRepository.getPhaseSnapshot()).thenAnswer( + (_) async => const Failure(failure), + ); + when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ); + }, + build: () => cubit, + act: (cubit) => cubit.load(), + expect: () => const [ + ProfilePhaseState(isLoading: true, isLoadingSummary: true), + ProfilePhaseState( + currentPhaseSummary: testProfileCurrentPhaseSummary, + phaseFailure: failure, + ), + ], + ); + + blocTest( + 'load stores summary failure when summary request fails', + setUp: () { + when(profileRepository.getPhaseSnapshot()).thenAnswer( + (_) async => const Success(phaseSnapshot), + ); + when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Failure(failure), + ); + }, + build: () => cubit, + act: (cubit) => cubit.load(), + expect: () => const [ + ProfilePhaseState(isLoading: true, isLoadingSummary: true), + ProfilePhaseState( + phaseSnapshot: phaseSnapshot, + summaryFailure: failure, + ), + ], + ); + + blocTest( + 'load ignores repeated calls while request is in progress', + setUp: () { + when(profileRepository.getPhaseSnapshot()).thenAnswer( + (_) async => const Success(phaseSnapshot), + ); + when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ); + }, + build: () => cubit, + act: (cubit) { + cubit.load(); + cubit.load(); + }, + expect: () => const [ + ProfilePhaseState(isLoading: true, isLoadingSummary: true), + ProfilePhaseState( + phaseSnapshot: phaseSnapshot, + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + ], + verify: (_) { + verify(profileRepository.getPhaseSnapshot()).called(1); + verify(statisticsRepository.getCurrentPhaseSummary()).called(1); + }, + ); + + blocTest( + 'reloadSummary refreshes summary without touching phase snapshot', + setUp: () => when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Success(testProfileCurrentPhaseSummary), + ), + build: () => cubit, + seed: () => const ProfilePhaseState(phaseSnapshot: phaseSnapshot), + act: (cubit) => cubit.reloadSummary(), + expect: () => const [ + ProfilePhaseState( + isLoadingSummary: true, + phaseSnapshot: phaseSnapshot, + ), + ProfilePhaseState( + phaseSnapshot: phaseSnapshot, + currentPhaseSummary: testProfileCurrentPhaseSummary, + ), + ], + verify: (_) { + verify(statisticsRepository.getCurrentPhaseSummary()).called(1); + verifyNever(profileRepository.getPhaseSnapshot()); + }, + ); + + blocTest( + 'reloadSummary stores failure when summary request fails', + setUp: () => when(statisticsRepository.getCurrentPhaseSummary()).thenAnswer( + (_) async => const Failure(failure), + ), + build: () => cubit, + seed: () => const ProfilePhaseState(phaseSnapshot: phaseSnapshot), + act: (cubit) => cubit.reloadSummary(), + expect: () => const [ + ProfilePhaseState( + isLoadingSummary: true, + phaseSnapshot: phaseSnapshot, + ), + ProfilePhaseState( + phaseSnapshot: phaseSnapshot, + summaryFailure: failure, + ), + ], + ); + }); +} 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 bc7cf16e..7abfe61f 100644 --- a/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart @@ -13,21 +13,28 @@ import 'package:moveup_flutter/features/profile/domain/entities/profile_statisti 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'; +import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.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()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) void main() { late MockProfileStatisticsRepository repository; + late MockProfileRepository profileRepository; late ProfileStatisticsCubit cubit; const failure = ProfileRequestFailure('error_message'); setUp(() { repository = MockProfileStatisticsRepository(); - cubit = ProfileStatisticsCubit(repository); + profileRepository = MockProfileRepository(); + cubit = ProfileStatisticsCubit(repository, profileRepository); provideDummy>( const Success(testProfileStatisticsVolumeData), ); @@ -46,6 +53,9 @@ void main() { provideDummy, ProfileFailure>>( const Success(testProfileStatisticsWorkouts), ); + provideDummy>( + const Success(testProfileStatisticsHistorySnapshot), + ); }); group('ProfileStatisticsCubit', () { @@ -55,31 +65,29 @@ 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), ); + when(profileRepository.getStatsHistorySnapshot()).thenAnswer( + (_) async => const Success(testProfileStatisticsHistorySnapshot), + ); }, build: () => cubit, act: (cubit) => cubit.loadInitial(), expect: () => const [ - ProfileStatisticsState( - isLoading: true, - isLoadingCurrentPhaseSummary: true, - ), + ProfileStatisticsState(isLoading: true), ProfileStatisticsState( selectedExerciseId: 17, - currentPhaseSummary: testProfileCurrentPhaseSummary, volumeData: testProfileStatisticsVolumeData, exerciseOptions: testProfileStatisticsExercises, + historySnapshot: testProfileStatisticsHistorySnapshot, ), ], verify: (_) { verify(repository.getVolume()).called(1); - verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).called(1); + verify(profileRepository.getStatsHistorySnapshot()).called(1); + verifyNever(repository.getCurrentPhaseSummary()); }, ); @@ -87,105 +95,30 @@ 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), ); + when(profileRepository.getStatsHistorySnapshot()).thenAnswer( + (_) async => const Success(testProfileStatisticsHistorySnapshot), + ); }, build: () => cubit, act: (cubit) => cubit.loadInitial(), expect: () => const [ + ProfileStatisticsState(isLoading: true), ProfileStatisticsState( - isLoading: true, - isLoadingCurrentPhaseSummary: true, - ), - ProfileStatisticsState( - currentPhaseSummary: testProfileCurrentPhaseSummary, + historySnapshot: testProfileStatisticsHistorySnapshot, failure: failure, ), ], verify: (_) { verify(repository.getVolume()).called(1); - verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).called(1); + verify(profileRepository.getStatsHistorySnapshot()).called(1); + verifyNever(repository.getCurrentPhaseSummary()); }, ); - 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( @@ -506,16 +439,13 @@ void main() { ); blocTest( - 'stores history snapshot and switches history tab', + 'selectHistoryTab updates the visible tab', build: () => cubit, - act: (cubit) { - cubit.setHistorySnapshot(testProfileStatisticsHistorySnapshot); - cubit.selectHistoryTab(ProfileHistoryTab.tests); - }, + seed: () => const ProfileStatisticsState( + historySnapshot: testProfileStatisticsHistorySnapshot, + ), + act: (cubit) => cubit.selectHistoryTab(ProfileHistoryTab.tests), expect: () => const [ - ProfileStatisticsState( - historySnapshot: testProfileStatisticsHistorySnapshot, - ), ProfileStatisticsState( selectedHistoryTab: ProfileHistoryTab.tests, historySnapshot: testProfileStatisticsHistorySnapshot, diff --git a/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart b/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart index 89878f14..aa600e59 100644 --- a/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart @@ -2,9 +2,11 @@ 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/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/domain/repositories/profile_repository.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'; @@ -13,9 +15,13 @@ import '../../../subscriptions/support/subscriptions_dto_fixtures.dart'; import '../../support/profile_dto_fixtures.dart'; import 'profile_subscription_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) void main() { - late MockSubscriptionsRepository repository; + late MockProfileRepository profileRepository; + late MockSubscriptionsRepository subscriptionsRepository; late ProfileSubscriptionCubit cubit; const activeSubscription = ProfileActiveSubscriptionSnapshot( @@ -28,8 +34,12 @@ void main() { final item = createSubscriptionCatalogItems().last; setUp(() { - repository = MockSubscriptionsRepository(); - cubit = ProfileSubscriptionCubit(repository); + profileRepository = MockProfileRepository(); + subscriptionsRepository = MockSubscriptionsRepository(); + cubit = ProfileSubscriptionCubit(profileRepository, subscriptionsRepository); + provideDummy>( + const Success(activeSubscription), + ); provideDummy>( Success(item), ); @@ -42,104 +52,121 @@ void main() { group('ProfileSubscriptionCubit', () { blocTest( - 'emits empty state when active subscription is absent', + 'load emits empty state when backend reports no active subscription', + setUp: () => when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Success(null), + ), build: () => cubit, seed: () => ProfileSubscriptionState( activeSubscription: activeSubscription, item: item, - failure: const SubscriptionsRequestFailure('test'), ), - act: (cubit) => cubit.syncActiveSubscription(null), - expect: () => const [ - ProfileSubscriptionState(), + act: (cubit) => cubit.load(), + expect: () => [ + ProfileSubscriptionState( + isLoading: true, + activeSubscription: activeSubscription, + item: item, + ), + const ProfileSubscriptionState(), ], - verify: (_) => verifyNever(repository.getSubscriptions()), + verify: (_) { + verify(profileRepository.getActiveSubscription()).called(1); + verifyNever(subscriptionsRepository.getSubscriptions()); + }, ); blocTest( - 'loads details when active subscription appears', - setUp: () => when(repository.getSubscriptions()).thenAnswer( - (_) async => Success, SubscriptionsFailure>( - createSubscriptionCatalogItems(), - ), - ), + 'load resolves catalog item when active subscription is present', + setUp: () { + when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Success(activeSubscription), + ); + when(subscriptionsRepository.getSubscriptions()).thenAnswer( + (_) async => Success, SubscriptionsFailure>( + createSubscriptionCatalogItems(), + ), + ); + }, build: () => cubit, - act: (cubit) => cubit.syncActiveSubscription(activeSubscription), + act: (cubit) => cubit.load(), expect: () => [ - const ProfileSubscriptionState( - isLoading: true, - activeSubscription: activeSubscription, - ), + const ProfileSubscriptionState(isLoading: true), ProfileSubscriptionState( activeSubscription: activeSubscription, item: item, ), ], - verify: (_) => verify(repository.getSubscriptions()).called(1), + verify: (_) { + verify(profileRepository.getActiveSubscription()).called(1); + verify(subscriptionsRepository.getSubscriptions()).called(1); + }, ); blocTest( - 'ignores duplicate sync with same subscriptionId', - build: () => cubit, - seed: () => ProfileSubscriptionState( - activeSubscription: activeSubscription, - item: item, + 'load stores profile failure when active subscription request fails', + setUp: () => when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Failure(ProfileRequestFailure('error_message')), ), - act: (cubit) => cubit.syncActiveSubscription(activeSubscription), - expect: () => const [], - verify: (_) => verifyNever(repository.getSubscriptions()), + build: () => cubit, + act: (cubit) => cubit.load(), + expect: () => const [ + ProfileSubscriptionState(isLoading: true), + ProfileSubscriptionState(failure: ProfileRequestFailure('error_message')), + ], + verify: (_) { + verify(profileRepository.getActiveSubscription()).called(1); + verifyNever(subscriptionsRepository.getSubscriptions()); + }, ); blocTest( - 'emits failed retry state when details request fails', - setUp: () => when(repository.getSubscriptions()).thenAnswer( - (_) async => const Failure, SubscriptionsFailure>( - SubscriptionsRequestFailure('error_message'), - ), - ), + 'load emits failed retry state when catalog request fails', + setUp: () { + when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Success(activeSubscription), + ); + when(subscriptionsRepository.getSubscriptions()).thenAnswer( + (_) async => const Failure, SubscriptionsFailure>( + SubscriptionsRequestFailure('error_message'), + ), + ); + }, build: () => cubit, - act: (cubit) => cubit.syncActiveSubscription(activeSubscription), + act: (cubit) => cubit.load(), expect: () => const [ - ProfileSubscriptionState( - isLoading: true, - activeSubscription: activeSubscription, - ), + ProfileSubscriptionState(isLoading: true), 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(), - ), - ), + 'load matches active subscription to catalog item by name and price instead of id', + setUp: () { + when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Success( + ProfileActiveSubscriptionSnapshot( + id: 90, + name: '3 месяца', + price: '1400.00', + startDate: testProfileSubscriptionStartDate, + endDate: testProfileSubscriptionEndDate, + ), + ), + ); + when(subscriptionsRepository.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, - ), - ), + act: (cubit) => cubit.load(), expect: () => [ - const ProfileSubscriptionState( - isLoading: true, - activeSubscription: ProfileActiveSubscriptionSnapshot( - id: 90, - name: '3 месяца', - price: '1400.00', - startDate: testProfileSubscriptionStartDate, - endDate: testProfileSubscriptionEndDate, - ), - ), + const ProfileSubscriptionState(isLoading: true), ProfileSubscriptionState( activeSubscription: const ProfileActiveSubscriptionSnapshot( id: 90, @@ -151,7 +178,25 @@ void main() { item: item, ), ], - verify: (_) => verify(repository.getSubscriptions()).called(1), + ); + + blocTest( + 'load ignores repeated calls while request is in progress', + setUp: () { + when(profileRepository.getActiveSubscription()).thenAnswer( + (_) async => const Success(null), + ); + }, + build: () => cubit, + act: (cubit) { + cubit.load(); + cubit.load(); + }, + expect: () => const [ + ProfileSubscriptionState(isLoading: true), + ProfileSubscriptionState(), + ], + verify: (_) => verify(profileRepository.getActiveSubscription()).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 index f473f708..48652159 100644 --- a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -5,10 +5,6 @@ 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'; import 'package:moveup_flutter/features/profile/presentation/cubits/profile_user_cubit.dart'; @@ -37,15 +33,6 @@ void main() { repository = MockProfileRepository(); cubit = ProfileUserCubit(repository, seedUser: seedUser); provideDummy>(const Success(seedUser)); - provideDummy>( - Success(createProfileStatsHistorySnapshot()), - ); - provideDummy>( - Success(createProfilePhaseSnapshot()), - ); - provideDummy>( - Success(createProfileParametersSnapshot()), - ); }); group('ProfileUserCubit', () { @@ -53,15 +40,6 @@ void main() { 'emits loading and refreshed user when refresh succeeds', setUp: () { when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)); - when(repository.getStatsHistorySnapshot()).thenAnswer( - (_) async => Success(createProfileStatsHistorySnapshot()), - ); - when(repository.getPhaseSnapshot()).thenAnswer( - (_) async => Success(createProfilePhaseSnapshot()), - ); - when(repository.getParametersSnapshot()).thenAnswer( - (_) async => Success(createProfileParametersSnapshot()), - ); }, build: () => cubit, act: (cubit) => cubit.refresh(), @@ -72,45 +50,10 @@ 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, - ), - ), - phaseSnapshot: ProfilePhaseSnapshot( - 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); }, ); @@ -118,15 +61,6 @@ void main() { 'emits loading only once when refresh is called twice in progress', setUp: () { when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)); - when(repository.getStatsHistorySnapshot()).thenAnswer( - (_) async => Success(createProfileStatsHistorySnapshot()), - ); - when(repository.getPhaseSnapshot()).thenAnswer( - (_) async => Success(createProfilePhaseSnapshot()), - ); - when(repository.getParametersSnapshot()).thenAnswer( - (_) async => Success(createProfileParametersSnapshot()), - ); }, build: () => cubit, act: (cubit) { @@ -140,45 +74,10 @@ 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, - ), - ), - phaseSnapshot: ProfilePhaseSnapshot( - 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 cd72ab6d..95536247 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -1,5 +1,7 @@ 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/focused/profile_history_response_dto.dart'; +import 'package:moveup_flutter/features/profile/data/dto/focused/profile_phase_response_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'; @@ -99,6 +101,22 @@ ProfilePhaseDto createProfilePhaseDto({ currentPhase: currentPhase ?? createProfileCurrentPhaseDto(), ); +/// Test fixture for [ProfilePhaseResponseDto]. +ProfilePhaseResponseDto createProfilePhaseResponseDto({ + ProfilePhaseDto? phase, +}) => ProfilePhaseResponseDto(phase: phase ?? createProfilePhaseDto()); + +/// Test fixture for [ProfileHistoryResponseDto]. +ProfileHistoryResponseDto createProfileHistoryResponseDto({ + List? workouts, + List? tests, +}) => ProfileHistoryResponseDto( + data: ProfileHistoryDataDto( + workouts: workouts ?? [createProfileWorkoutHistoryItemDto()], + tests: tests ?? [createProfileTestHistoryItemDto()], + ), +); + /// Test fixture for [ProfileCurrentPhaseDto]. ProfileCurrentPhaseDto createProfileCurrentPhaseDto({ int id = testProfilePhaseId, @@ -203,19 +221,9 @@ ProfileTestHistoryItemDto createProfileTestHistoryItemDto({ /// 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( diff --git a/test/features/profile/support/profile_statistics_dto_fixtures.dart b/test/features/profile/support/profile_statistics_dto_fixtures.dart index 04eca33f..95c273fc 100644 --- a/test/features/profile/support/profile_statistics_dto_fixtures.dart +++ b/test/features/profile/support/profile_statistics_dto_fixtures.dart @@ -124,13 +124,6 @@ const testProfileStatisticsYearFrequencyData = FrequencyStatisticsData( ); 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: 'Утренняя зарядка',