From 76a8e887323a939504cb97e571d74c2a983da9d7 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 09:31:02 +0700 Subject: [PATCH 1/8] refactor(profile): add focused profile endpoints to ApiClient Adds focused-response DTOs and ProfileApiClient methods for /profile/user, /profile/active-subscription, /profile/user-parameters, /profile/history and /profile/phase. Prepares profile sections to load their own data instead of slicing the monolithic /profile payload. Co-Authored-By: Claude Opus 4.7 --- lib/core/network/api_paths.dart | 15 +++++++ ...file_active_subscription_response_dto.dart | 21 ++++++++++ .../focused/profile_history_response_dto.dart | 39 +++++++++++++++++++ .../focused/profile_phase_response_dto.dart | 19 +++++++++ .../profile_user_only_response_dto.dart | 19 +++++++++ .../profile_user_parameters_response_dto.dart | 21 ++++++++++ .../data/remote/profile_api_client.dart | 27 ++++++++++++- 7 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 lib/features/profile/data/dto/focused/profile_active_subscription_response_dto.dart create mode 100644 lib/features/profile/data/dto/focused/profile_history_response_dto.dart create mode 100644 lib/features/profile/data/dto/focused/profile_phase_response_dto.dart create mode 100644 lib/features/profile/data/dto/focused/profile_user_only_response_dto.dart create mode 100644 lib/features/profile/data/dto/focused/profile_user_parameters_response_dto.dart diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 856f009..3200550 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 0000000..14c6368 --- /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 0000000..4ed2dc7 --- /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 0000000..9f49305 --- /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 0000000..5af214e --- /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 0000000..0553cdf --- /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/remote/profile_api_client.dart b/lib/features/profile/data/remote/profile_api_client.dart index 5103d88..b7528fd 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); From c39a8a52860fe1b946cc008524ba43778652729c Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 10:03:24 +0700 Subject: [PATCH 2/8] refactor(profile): move current phase section to dedicated cubit ProfilePhaseCubit owns phase snapshot and current phase summary, loads them from focused /profile/phase and statistics endpoints. ProfileStatisticsCubit no longer carries phase summary state. ProfileRepository.getPhaseSnapshot now uses the focused endpoint and drops the cross-populated phase cache field. Co-Authored-By: Claude Opus 4.7 --- .../profile_phase_response_mapper.dart | 11 ++ .../repositories/profile_repository_impl.dart | 21 +- .../cubits/profile_phase_cubit.dart | 106 ++++++++++ .../cubits/profile_phase_state.dart | 15 ++ .../cubits/profile_statistics_cubit.dart | 60 +----- .../cubits/profile_statistics_state.dart | 3 - .../pages/profile_page_builder.dart | 7 + .../widgets/current_phase_section_widget.dart | 59 +++--- .../profile_parameters_section_widget.dart | 17 +- .../profile_repository_impl_test.dart | 64 +++--- .../cubits/profile_phase_cubit_test.dart | 182 ++++++++++++++++++ .../cubits/profile_statistics_cubit_test.dart | 99 +--------- .../profile/support/profile_dto_fixtures.dart | 6 + 13 files changed, 397 insertions(+), 253 deletions(-) create mode 100644 lib/features/profile/data/mappers/profile_phase_response_mapper.dart create mode 100644 lib/features/profile/presentation/cubits/profile_phase_cubit.dart create mode 100644 lib/features/profile/presentation/cubits/profile_phase_state.dart create mode 100644 test/features/profile/presentation/cubits/profile_phase_cubit_test.dart 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 0000000..7c5d491 --- /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/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index c1cf971..2f05758 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -14,7 +14,7 @@ import '../../domain/repositories/profile_repository.dart'; import '../dto/change_password_request_dto.dart'; import '../dto/update_profile_request_dto.dart'; import '../mappers/profile_failure_mapper.dart'; -import '../mappers/profile_phase_snapshot_mapper.dart'; +import '../mappers/profile_phase_response_mapper.dart'; import '../mappers/profile_parameters_mapper.dart'; import '../mappers/profile_history_snapshot_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; @@ -25,7 +25,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; - ProfilePhaseSnapshot? _cachedPhaseSnapshot; ProfileParametersSnapshot? _cachedParametersSnapshot; bool _hasCachedParametersSnapshot = false; @@ -37,7 +36,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { 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()); @@ -63,7 +61,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { 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); @@ -80,19 +77,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()); @@ -114,7 +101,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { 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); @@ -166,7 +152,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { final refreshedResponse = await _apiClient.getProfile(); _cachedStatsHistorySnapshot = refreshedResponse.data.toStatsHistorySnapshot(); - _cachedPhaseSnapshot = refreshedResponse.data.toPhaseSnapshot(); _cachedParametersSnapshot = refreshedResponse.data.toParametersSnapshot(); _hasCachedParametersSnapshot = true; return Result.success(refreshedResponse.data.user.toEntity()); 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 0000000..51572a9 --- /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 0000000..69880d9 --- /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_statistics_cubit.dart b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart index 4efe207..7280286 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'; @@ -29,34 +28,16 @@ final class ProfileStatisticsCubit extends Cubit { 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 volumeResult = await volumeFuture; final exercisesResult = await exercisesFuture; - final currentPhaseSummaryResult = await currentPhaseSummaryFuture; if (isClosed) return; - final currentPhaseSummary = switch (currentPhaseSummaryResult) { - Success(data: final summary) => summary, - Failure() => state.currentPhaseSummary, - }; - final currentPhaseSummaryFailure = switch (currentPhaseSummaryResult) { - Success() => null, - Failure(:final error) => error, - }; - switch (volumeResult) { case Success(data: final volumeData): final exerciseOptions = switch (exercisesResult) { @@ -66,13 +47,10 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, - isLoadingCurrentPhaseSummary: false, mode: ProfileStatisticsMode.volume, selectedExerciseId: volumeData.exerciseId, - currentPhaseSummary: currentPhaseSummary, volumeData: volumeData, exerciseOptions: exerciseOptions, - currentPhaseSummaryFailure: currentPhaseSummaryFailure, failure: null, ), ); @@ -80,9 +58,6 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, - isLoadingCurrentPhaseSummary: false, - currentPhaseSummary: currentPhaseSummary, - currentPhaseSummaryFailure: currentPhaseSummaryFailure, failure: error, ), ); @@ -231,39 +206,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 7bd7a07..f91370c 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/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 8092c1e..41b0d71 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -20,6 +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_phase_cubit.dart'; import '../cubits/profile_refresh_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_subscription_cubit.dart'; @@ -52,6 +53,12 @@ class ProfilePageBuilder extends StatelessWidget { di(), )..loadInitial(), ), + BlocProvider( + create: (_) => ProfilePhaseCubit( + di(), + di(), + )..load(), + ), BlocProvider( create: (_) => ProfileParametersCubit( 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 773fbfc..c0965cc 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 1f9c4f9..4054ef1 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/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 5e83eed..84dd506 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -7,6 +7,7 @@ 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_phase_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'; @@ -411,35 +412,10 @@ void main() { }); 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,18 +438,38 @@ void main() { ), ); - verify(apiClient.getProfile()).called(1); + verify(apiClient.getPhase()).called(1); + verifyNoMoreInteractions(apiClient); + }); + + test('returns empty snapshot when backend returns no active phase', () async { + // Arrange + when(apiClient.getPhase()).thenAnswer( + (_) async => const ProfilePhaseResponseDto(phase: null), + ); + + // Act + final result = await repository.getPhaseSnapshot(); + + // Assert + expect(result.isSuccess, isTrue); + expect( + result.success, + const ProfilePhaseSnapshot(hasProgress: false, currentPhaseName: null), + ); + + 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.getPhaseSnapshot(); @@ -483,14 +479,14 @@ void main() { 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.getPhaseSnapshot(); @@ -500,7 +496,7 @@ void main() { 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_phase_cubit_test.dart b/test/features/profile/presentation/cubits/profile_phase_cubit_test.dart new file mode 100644 index 0000000..f95763d --- /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 bc7cf16..591b88f 100644 --- a/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_statistics_cubit_test.dart @@ -55,9 +55,6 @@ 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), ); @@ -65,21 +62,17 @@ void main() { 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, ), ], verify: (_) { verify(repository.getVolume()).called(1); - verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).called(1); + verifyNever(repository.getCurrentPhaseSummary()); }, ); @@ -87,9 +80,6 @@ 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), ); @@ -97,95 +87,16 @@ void main() { build: () => cubit, act: (cubit) => cubit.loadInitial(), expect: () => const [ - ProfileStatisticsState( - isLoading: true, - isLoadingCurrentPhaseSummary: true, - ), - ProfileStatisticsState( - currentPhaseSummary: testProfileCurrentPhaseSummary, - failure: failure, - ), + ProfileStatisticsState(isLoading: true), + ProfileStatisticsState(failure: failure), ], verify: (_) { verify(repository.getVolume()).called(1); - verify(repository.getCurrentPhaseSummary()).called(1); verify(repository.getExercises()).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( diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart index cd72ab6..8335886 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -1,5 +1,6 @@ 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_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 +100,11 @@ ProfilePhaseDto createProfilePhaseDto({ currentPhase: currentPhase ?? createProfileCurrentPhaseDto(), ); +/// Test fixture for [ProfilePhaseResponseDto]. +ProfilePhaseResponseDto createProfilePhaseResponseDto({ + ProfilePhaseDto? phase, +}) => ProfilePhaseResponseDto(phase: phase ?? createProfilePhaseDto()); + /// Test fixture for [ProfileCurrentPhaseDto]. ProfileCurrentPhaseDto createProfileCurrentPhaseDto({ int id = testProfilePhaseId, From d7bcc77c362c50aa073265c07f3ee000f943065e Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 10:10:17 +0700 Subject: [PATCH 3/8] refactor(profile): move subscription section to self-loading cubit ProfileSubscriptionCubit now loads its own active subscription via /profile/active-subscription and resolves the matching catalog item, so ProfileSubscriptionSectionWidget no longer depends on the user cubit history snapshot. ProfileRefreshCubit listener also triggers the new cubit so the post-purchase reload path keeps working. Co-Authored-By: Claude Opus 4.7 --- ...e_active_subscription_response_mapper.dart | 19 ++ .../repositories/profile_repository_impl.dart | 17 ++ .../repositories/profile_repository.dart | 3 + .../cubits/profile_subscription_cubit.dart | 95 +++++---- .../cubits/profile_subscription_state.dart | 2 +- .../presentation/pages/profile_page.dart | 6 +- .../pages/profile_page_builder.dart | 3 +- .../profile_subscription_section_widget.dart | 33 +--- .../profile_subscription_cubit_test.dart | 181 +++++++++++------- 9 files changed, 204 insertions(+), 155 deletions(-) create mode 100644 lib/features/profile/data/mappers/profile_active_subscription_response_mapper.dart 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 0000000..cc97647 --- /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/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index 2f05758..fb7fcad 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -13,6 +13,7 @@ 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_response_mapper.dart'; import '../mappers/profile_parameters_mapper.dart'; @@ -91,6 +92,22 @@ final class ProfileRepositoryImpl implements ProfileRepository { } } + @override + Future> getActiveSubscription() async { + try { + 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('GetActiveSubscription failed with unexpected error', e, s); + return Result.failure( + UnknownProfileFailure(parentException: e, stackTrace: s), + ); + } + } + @override Future> getParametersSnapshot() async { if (_hasCachedParametersSnapshot) { diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index 345c145..55718cd 100644 --- a/lib/features/profile/domain/repositories/profile_repository.dart +++ b/lib/features/profile/domain/repositories/profile_repository.dart @@ -16,6 +16,9 @@ abstract interface class ProfileRepository { /// Returns the current phase snapshot for the current phase section. Future> getPhaseSnapshot(); + /// Returns the current active subscription snapshot for the subscription section. + Future> getActiveSubscription(); + /// Returns the current parameters snapshot for the profile parameters section. Future> getParametersSnapshot(); diff --git a/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart b/lib/features/profile/presentation/cubits/profile_subscription_cubit.dart index cc6544e..1864ff9 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 363421a..db00cbd 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/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 2245c47..92d0f69 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -17,6 +17,7 @@ import '../../../auth/presentation/cubits/auth_session_cubit.dart'; import '../cubits/profile_parameters_cubit.dart'; import '../cubits/profile_refresh_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; +import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; import '../widgets/current_phase_section_widget.dart'; @@ -82,6 +83,7 @@ class ProfilePage extends StatelessWidget { if (!state.shouldRefresh) return; context.read().consumeRefreshRequest(); unawaited(context.read().refresh()); + unawaited(context.read().load()); }, ), BlocListener( @@ -135,9 +137,7 @@ class ProfilePage extends StatelessWidget { child: const Text(AppStrings.profileStatsHistoryButton), ), const SizedBox(height: 36), - ProfileSubscriptionSectionWidget( - activeSubscription: state.historySnapshot?.activeSubscription, - ), + const ProfileSubscriptionSectionWidget(), const SizedBox(height: 36), const ProfileCardsSectionWidget(), const SizedBox(height: 36), diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 41b0d71..3b3a980 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -66,8 +66,9 @@ class ProfilePageBuilder extends StatelessWidget { ), BlocProvider( create: (_) => ProfileSubscriptionCubit( + di(), di(), - ), + )..load(), ), BlocProvider( create: (_) => CardsCubit( 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 07a2d6b..351fd02 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/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart b/test/features/profile/presentation/cubits/profile_subscription_cubit_test.dart index 89878f1..aa600e5 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), ); }); } From c1a9660c5ae180ca3154792745d4faea9276a428 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 10:14:44 +0700 Subject: [PATCH 4/8] refactor(profile): drop parameters bootstrap snapshot path ProfileParametersCubit now seeds the form only from its own loadInitial() / submit() flow. Removed setBootstrapSnapshot, the bootstrapSnapshot state field, and the ProfilePage listener that proxied parameters from the monolithic /profile payload. Co-Authored-By: Claude Opus 4.7 --- .../cubits/profile_parameters_cubit.dart | 27 -------- .../cubits/profile_parameters_state.dart | 1 - .../presentation/pages/profile_page.dart | 7 +- .../cubits/profile_parameters_cubit_test.dart | 67 ------------------- 4 files changed, 1 insertion(+), 101 deletions(-) diff --git a/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart b/lib/features/profile/presentation/cubits/profile_parameters_cubit.dart index 156aa6b..31c83fe 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 6532b78..40eb646 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/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 92d0f69..c7a52b2 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -14,7 +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_subscription_cubit.dart'; @@ -87,16 +86,12 @@ class ProfilePage extends StatelessWidget { }, ), BlocListener( - listenWhen: (previous, current) => - previous.historySnapshot != current.historySnapshot || - previous.parametersSnapshot != current.parametersSnapshot, + listenWhen: (previous, current) => previous.historySnapshot != current.historySnapshot, listener: (context, state) { final historySnapshot = state.historySnapshot; if (historySnapshot != null) { context.read().setHistorySnapshot(historySnapshot); } - - context.read().setBootstrapSnapshot(state.parametersSnapshot); }, ), ], 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 47142a3..ba220f9 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, From 2f88b5ef740ae80a7ea97bc5bc54ac699e47eec1 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 10:27:50 +0700 Subject: [PATCH 5/8] refactor(profile): statistics cubit loads history from /profile/history ProfileStatisticsCubit now requests the stats history snapshot itself during loadInitial, so the ProfileUserCubit listener no longer proxies history through setHistorySnapshot. ProfileStatsHistorySnapshot drops its activeSubscription field; the history dialog reads the active subscription directly from ProfileSubscriptionCubit. The repository stats history snapshot now hits the focused endpoint and the cache field disappears. Co-Authored-By: Claude Opus 4.7 --- .../profile_history_response_mapper.dart | 43 ++++++ .../profile_history_snapshot_mapper.dart | 12 -- .../repositories/profile_repository_impl.dart | 19 +-- .../profile_stats_history_snapshot.dart | 6 +- .../cubits/profile_statistics_cubit.dart | 23 ++-- .../presentation/pages/profile_page.dart | 30 ++--- .../pages/profile_page_builder.dart | 1 + .../widgets/stats/profile_history_dialog.dart | 76 ++++++----- .../profile_repository_impl_test.dart | 126 ++++++------------ .../cubits/profile_statistics_cubit_test.dart | 41 ++++-- .../cubits/profile_user_cubit_test.dart | 14 -- .../profile/support/profile_dto_fixtures.dart | 22 +-- .../profile_statistics_dto_fixtures.dart | 7 - 13 files changed, 201 insertions(+), 219 deletions(-) create mode 100644 lib/features/profile/data/mappers/profile_history_response_mapper.dart 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 0000000..d1cb9ac --- /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 aca2241..54a5316 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/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index fb7fcad..2f27b28 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -15,9 +15,9 @@ 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_history_response_mapper.dart'; import '../mappers/profile_phase_response_mapper.dart'; import '../mappers/profile_parameters_mapper.dart'; -import '../mappers/profile_history_snapshot_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -25,7 +25,6 @@ import '../remote/profile_api_client.dart'; final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; - ProfileStatsHistorySnapshot? _cachedStatsHistorySnapshot; ProfileParametersSnapshot? _cachedParametersSnapshot; bool _hasCachedParametersSnapshot = false; @@ -36,7 +35,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { Future> getUser() async { try { final response = await _apiClient.getProfile(); - _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); _cachedParametersSnapshot = response.data.toParametersSnapshot(); _hasCachedParametersSnapshot = true; return Result.success(response.data.user.toEntity()); @@ -53,18 +51,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; - _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()); @@ -117,7 +106,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { try { final response = await _apiClient.getProfile(); final snapshot = response.data.toParametersSnapshot(); - _cachedStatsHistorySnapshot = response.data.toStatsHistorySnapshot(); _cachedParametersSnapshot = snapshot; _hasCachedParametersSnapshot = true; return Result.success(snapshot); @@ -168,7 +156,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { } final refreshedResponse = await _apiClient.getProfile(); - _cachedStatsHistorySnapshot = refreshedResponse.data.toStatsHistorySnapshot(); _cachedParametersSnapshot = refreshedResponse.data.toParametersSnapshot(); _hasCachedParametersSnapshot = true; return Result.success(refreshedResponse.data.user.toEntity()); 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 9a5d95e..46f9859 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/presentation/cubits/profile_statistics_cubit.dart b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart index 7280286..8eaa4b3 100644 --- a/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart +++ b/lib/features/profile/presentation/cubits/profile_statistics_cubit.dart @@ -12,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'; @@ -20,9 +21,13 @@ 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 { @@ -32,12 +37,19 @@ final class ProfileStatisticsCubit extends Cubit { final volumeFuture = _repository.getVolume(); final exercisesFuture = _repository.getExercises(); + final historyFuture = _profileRepository.getStatsHistorySnapshot(); final volumeResult = await volumeFuture; final exercisesResult = await exercisesFuture; + final historyResult = await historyFuture; if (isClosed) return; + final historySnapshot = switch (historyResult) { + Success(:final data) => data, + Failure() => state.historySnapshot, + }; + switch (volumeResult) { case Success(data: final volumeData): final exerciseOptions = switch (exercisesResult) { @@ -51,6 +63,7 @@ final class ProfileStatisticsCubit extends Cubit { selectedExerciseId: volumeData.exerciseId, volumeData: volumeData, exerciseOptions: exerciseOptions, + historySnapshot: historySnapshot, failure: null, ), ); @@ -58,19 +71,13 @@ final class ProfileStatisticsCubit extends Cubit { emit( state.copyWith( isLoading: false, + 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; diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index c7a52b2..267c4a2 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -15,7 +15,6 @@ 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_refresh_cubit.dart'; -import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; @@ -74,27 +73,14 @@ 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()); - unawaited(context.read().load()); - }, - ), - BlocListener( - listenWhen: (previous, current) => previous.historySnapshot != current.historySnapshot, - listener: (context, state) { - final historySnapshot = state.historySnapshot; - if (historySnapshot != null) { - context.read().setHistorySnapshot(historySnapshot); - } - }, - ), - ], + body: BlocListener( + listenWhen: (previous, current) => previous.shouldRefresh != current.shouldRefresh, + listener: (context, state) { + if (!state.shouldRefresh) return; + context.read().consumeRefreshRequest(); + unawaited(context.read().refresh()); + unawaited(context.read().load()); + }, child: BlocBuilder( builder: (context, state) { final user = state.user; diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index 3b3a980..d129587 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -51,6 +51,7 @@ class ProfilePageBuilder extends StatelessWidget { BlocProvider( create: (_) => ProfileStatisticsCubit( di(), + di(), )..loadInitial(), ), BlocProvider( 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 5fa366a..f9f7113 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/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart index 84dd506..6c3394a 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -7,6 +7,7 @@ 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/update_profile_request_dto.dart'; import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart'; @@ -254,64 +255,32 @@ void main() { }); group('getStatsHistorySnapshot', () { - test('returns snapshot from cache after getUser succeeds', () async { - // Arrange - when( - apiClient.getProfile(), - ).thenAnswer( - (_) async => createProfileUserResponseDto( - subscriptions: createProfileSubscriptionsDto(), - workouts: createProfileWorkoutsDto(), - tests: createProfileTestsDto(), - ), - ); - - // Act - final getUserResult = await repository.getUser(); - final historyResult = await repository.getStatsHistorySnapshot(); - - // Assert - expect(getUserResult.isSuccess, isTrue); - expect(historyResult.isSuccess, isTrue); - expect(historyResult.success, createProfileStatsHistorySnapshot()); - - verify(apiClient.getProfile()).called(1); - verifyNoMoreInteractions(apiClient); - }); - - test('returns latest sorted workout and test when cache is empty', () async { + 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', + ), + ], ), ); @@ -323,13 +292,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', @@ -343,42 +305,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(); @@ -388,14 +348,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(); @@ -405,7 +365,7 @@ 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); }); 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 591b88f..7abfe61 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', () { @@ -58,6 +68,9 @@ void main() { when(repository.getExercises()).thenAnswer( (_) async => const Success(testProfileStatisticsExercises), ); + when(profileRepository.getStatsHistorySnapshot()).thenAnswer( + (_) async => const Success(testProfileStatisticsHistorySnapshot), + ); }, build: () => cubit, act: (cubit) => cubit.loadInitial(), @@ -67,11 +80,13 @@ void main() { selectedExerciseId: 17, volumeData: testProfileStatisticsVolumeData, exerciseOptions: testProfileStatisticsExercises, + historySnapshot: testProfileStatisticsHistorySnapshot, ), ], verify: (_) { verify(repository.getVolume()).called(1); verify(repository.getExercises()).called(1); + verify(profileRepository.getStatsHistorySnapshot()).called(1); verifyNever(repository.getCurrentPhaseSummary()); }, ); @@ -83,16 +98,23 @@ void main() { 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(failure: failure), + ProfileStatisticsState( + historySnapshot: testProfileStatisticsHistorySnapshot, + failure: failure, + ), ], verify: (_) { verify(repository.getVolume()).called(1); verify(repository.getExercises()).called(1); + verify(profileRepository.getStatsHistorySnapshot()).called(1); verifyNever(repository.getCurrentPhaseSummary()); }, ); @@ -417,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_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart index f473f70..67804d4 100644 --- a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart +++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart @@ -73,13 +73,6 @@ 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, @@ -141,13 +134,6 @@ 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, diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart index 8335886..9553624 100644 --- a/test/features/profile/support/profile_dto_fixtures.dart +++ b/test/features/profile/support/profile_dto_fixtures.dart @@ -1,5 +1,6 @@ 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'; @@ -105,6 +106,17 @@ 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, @@ -209,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 04eca33..95c273f 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: 'Утренняя зарядка', From 1918d8590e9204ef5a4dbf566e01879b2147602b Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 29 May 2026 11:13:02 +0700 Subject: [PATCH 6/8] =?UTF-8?q?refactor(profile):=20D7=20=E2=80=94=20remov?= =?UTF-8?q?e=20ProfileRefreshCubit=20workaround?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete ProfileRefreshCubit / ProfileRefreshState / .freezed.dart - Remove DI singleton registration (di.dart) - Remove BlocProvider.value from ProfilePageBuilder - Unwrap BlocListener in ProfilePage body - Remove requestRefresh() call from SubscriptionsDetailsPage - Drop getParametersSnapshot() from ProfileRepository interface (was never implemented; parameters live in ProfileParametersRepository) - Update docs/architecture.md: remove workaround mention, add note about self-contained section cubits after decomposition - Fix stale tests: profile_user_cubit_test, profile_repository_impl_test (align with apiClient.getUser() / ProfileUserOnlyResponseDto) flutter analyze --fatal-infos: no issues flutter test: 485/485 green --- docs/architecture.md | 8 +- lib/core/di/di.dart | 5 - .../repositories/profile_repository_impl.dart | 39 +--- .../repositories/profile_repository.dart | 4 - .../cubits/profile_refresh_cubit.dart | 23 --- .../cubits/profile_refresh_state.dart | 10 -- .../cubits/profile_user_cubit.dart | 25 --- .../cubits/profile_user_state.dart | 3 - .../presentation/pages/profile_page.dart | 13 +- .../pages/profile_page_builder.dart | 4 - .../pages/subscriptions_details_page.dart | 3 - .../profile_repository_impl_test.dart | 167 +++--------------- .../cubits/profile_user_cubit_test.dart | 87 --------- 13 files changed, 30 insertions(+), 361 deletions(-) delete mode 100644 lib/features/profile/presentation/cubits/profile_refresh_cubit.dart delete mode 100644 lib/features/profile/presentation/cubits/profile_refresh_state.dart diff --git a/docs/architecture.md b/docs/architecture.md index 1b696be..62964c3 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 115de91..cdddff5 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/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart index 2f27b28..4ebcedd 100644 --- a/lib/features/profile/data/repositories/profile_repository_impl.dart +++ b/lib/features/profile/data/repositories/profile_repository_impl.dart @@ -8,7 +8,6 @@ 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'; @@ -17,7 +16,6 @@ import '../mappers/profile_active_subscription_response_mapper.dart'; import '../mappers/profile_failure_mapper.dart'; import '../mappers/profile_history_response_mapper.dart'; import '../mappers/profile_phase_response_mapper.dart'; -import '../mappers/profile_parameters_mapper.dart'; import '../mappers/profile_user_entity_mapper.dart'; import '../remote/profile_api_client.dart'; @@ -25,8 +23,6 @@ import '../remote/profile_api_client.dart'; final class ProfileRepositoryImpl implements ProfileRepository { final AppLogger _logger; final ProfileApiClient _apiClient; - ProfileParametersSnapshot? _cachedParametersSnapshot; - bool _hasCachedParametersSnapshot = false; /// Creates an instance of [ProfileRepositoryImpl]. ProfileRepositoryImpl(this._logger, this._apiClient); @@ -34,10 +30,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { @override Future> getUser() async { try { - final response = await _apiClient.getProfile(); - _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()); @@ -97,29 +91,6 @@ final class ProfileRepositoryImpl implements ProfileRepository { } } - @override - Future> getParametersSnapshot() async { - if (_hasCachedParametersSnapshot) { - return Result.success(_cachedParametersSnapshot); - } - - try { - final response = await _apiClient.getProfile(); - final snapshot = response.data.toParametersSnapshot(); - _cachedParametersSnapshot = snapshot; - _hasCachedParametersSnapshot = true; - return Result.success(snapshot); - } on DioException catch (e) { - final networkFailure = e.toNetworkFailure(); - return Result.failure(networkFailure.toProfileFailure()); - } catch (e, s) { - _logger.e('GetParametersSnapshot failed with unexpected error', e, s); - return Result.failure( - UnknownProfileFailure(parentException: e, stackTrace: s), - ); - } - } - @override Future> updateUser({ required User currentUser, @@ -155,10 +126,8 @@ final class ProfileRepositoryImpl implements ProfileRepository { await _apiClient.updateProfile(request); } - final refreshedResponse = await _apiClient.getProfile(); - _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/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart index 55718cd..3e10cc5 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. @@ -19,9 +18,6 @@ abstract interface class ProfileRepository { /// Returns the current active subscription snapshot for the subscription section. Future> getActiveSubscription(); - /// Returns the current parameters snapshot for the profile parameters section. - Future> getParametersSnapshot(); - /// Updates the current user profile and returns the canonical refreshed user payload. Future> updateUser({ required User currentUser, diff --git a/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart b/lib/features/profile/presentation/cubits/profile_refresh_cubit.dart deleted file mode 100644 index 927a848..0000000 --- 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 1e3b6f3..0000000 --- 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_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart index be49031..745c52f 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 8b2c650..a070f8b 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 267c4a2..4113f48 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -14,8 +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_refresh_cubit.dart'; -import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_user_cubit.dart'; import '../widgets/change_password_dialog.dart'; import '../widgets/current_phase_section_widget.dart'; @@ -73,15 +71,7 @@ class ProfilePage extends StatelessWidget { ), ], ), - body: BlocListener( - listenWhen: (previous, current) => previous.shouldRefresh != current.shouldRefresh, - listener: (context, state) { - if (!state.shouldRefresh) return; - context.read().consumeRefreshRequest(); - unawaited(context.read().refresh()); - unawaited(context.read().load()); - }, - child: BlocBuilder( + body: BlocBuilder( builder: (context, state) { final user = state.user; if (user == null) { @@ -132,7 +122,6 @@ class ProfilePage extends StatelessWidget { ); }, ), - ), ); } } diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart index d129587..fd73ef6 100644 --- a/lib/features/profile/presentation/pages/profile_page_builder.dart +++ b/lib/features/profile/presentation/pages/profile_page_builder.dart @@ -21,7 +21,6 @@ import '../../domain/repositories/profile_statistics_repository.dart'; import '../cubits/delete_profile_cubit.dart'; import '../cubits/profile_parameters_cubit.dart'; import '../cubits/profile_phase_cubit.dart'; -import '../cubits/profile_refresh_cubit.dart'; import '../cubits/profile_statistics_cubit.dart'; import '../cubits/profile_subscription_cubit.dart'; import '../cubits/profile_user_cubit.dart'; @@ -85,9 +84,6 @@ class ProfilePageBuilder extends StatelessWidget { BlocProvider( create: (_) => DeleteCardCubit(di()), ), - BlocProvider.value( - value: di(), - ), BlocProvider( create: (_) => LogoutCubit(di()), ), diff --git a/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart b/lib/features/subscriptions/presentation/pages/subscriptions_details_page.dart index 1e54225..0d3f3e8 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 6c3394a..f3a99c5 100644 --- a/test/features/profile/data/repositories/profile_repository_impl_test.dart +++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart @@ -9,11 +9,10 @@ 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'; @@ -40,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(); @@ -50,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(); @@ -71,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(); @@ -88,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); }); @@ -114,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)); @@ -134,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( @@ -159,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); }); @@ -174,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'), ), ); @@ -198,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); }); @@ -214,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', @@ -249,7 +248,7 @@ 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); }); }); @@ -462,130 +461,6 @@ void main() { }); }); - group('getParametersSnapshot', () { - test('returns snapshot from cache after getUser succeeds', () async { - // Arrange - when( - apiClient.getProfile(), - ).thenAnswer( - (_) async => createProfileUserResponseDto( - parameters: createProfileParametersInProfileDto(), - ), - ); - - // Act - final getUserResult = await repository.getUser(); - final parametersResult = await repository.getParametersSnapshot(); - - // Assert - expect(getUserResult.isSuccess, isTrue); - expect(parametersResult.isSuccess, isTrue); - expect(parametersResult.success, createProfileParametersSnapshot()); - - verify(apiClient.getProfile()).called(1); - verifyNoMoreInteractions(apiClient); - }); - - test('returns parameters snapshot from /profile when cache is empty', () async { - // Arrange - when( - apiClient.getProfile(), - ).thenAnswer( - (_) async => createProfileUserResponseDto( - parameters: createProfileParametersInProfileDto( - goal: 'Снижение веса', - gender: 'male', - age: 24, - weight: 73.5, - height: 180, - equipment: 'Зал', - level: 'Начинающий', - ), - ), - ); - - // Act - final result = await repository.getParametersSnapshot(); - - // Assert - expect(result.isSuccess, isTrue); - expect( - result.success, - const ProfileParametersSnapshot( - goal: 'Снижение веса', - gender: ProfileParametersGender.male, - age: 24, - weight: 73.5, - height: 180, - equipment: 'Зал', - level: 'Начинающий', - ), - ); - - verify(apiClient.getProfile()).called(1); - verifyNoMoreInteractions(apiClient); - }); - - test('does not refetch when server parameters are null', () async { - // Arrange - when(apiClient.getProfile()).thenAnswer( - (_) async => createProfileUserResponseDto(), - ); - - // Act - final firstResult = await repository.getParametersSnapshot(); - final secondResult = await repository.getParametersSnapshot(); - - // Assert - expect(firstResult.isSuccess, isTrue); - expect(firstResult.success, isNull); - expect(secondResult.isSuccess, isTrue); - expect(secondResult.success, isNull); - - verify(apiClient.getProfile()).called(1); - verifyNoMoreInteractions(apiClient); - }); - - test('returns ProfileRequestFailure when api returns server error', () async { - // Arrange - final exception = createProfileDioBadResponseException( - path: '/api/profile', - statusCode: 500, - code: 'server_error', - ); - when(apiClient.getProfile()).thenThrow(exception); - - // Act - final result = await repository.getParametersSnapshot(); - - // Assert - expect(result.isFailure, isTrue); - expect(result.failure, isA()); - expect(result.failure!.parentException, exception); - - verify(apiClient.getProfile()).called(1); - verifyNoMoreInteractions(apiClient); - }); - - test('returns UnknownProfileFailure when unexpected exception occurs', () async { - // Arrange - final exception = Exception('unexpected_error'); - when(apiClient.getProfile()).thenThrow(exception); - - // Act - final result = await repository.getParametersSnapshot(); - - // Assert - expect(result.isFailure, isTrue); - expect(result.failure, isA()); - expect(result.failure!.parentException, exception); - - verify(apiClient.getProfile()).called(1); - verify(logger.e(any, exception, any)).called(1); - verifyNoMoreInteractions(apiClient); - }); - }); - group('changePassword', () { test('returns success when api succeeds', () async { // Arrange diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart index 67804d4..4865215 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,38 +50,10 @@ void main() { ), ProfileUserState( user: updatedUser, - historySnapshot: ProfileStatsHistorySnapshot( - 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); }, ); @@ -111,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) { @@ -133,38 +74,10 @@ void main() { ), ProfileUserState( user: updatedUser, - historySnapshot: ProfileStatsHistorySnapshot( - 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); }, ); From 44080d39deff561ab3d63a1a0f9804053c5b4125 Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 12 Jun 2026 15:18:06 +0700 Subject: [PATCH 7/8] style(profile): fix dart format violation in profile_page --- .../presentation/pages/profile_page.dart | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 4113f48..411e354 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -72,56 +72,56 @@ class ProfilePage extends StatelessWidget { ], ), body: BlocBuilder( - builder: (context, state) { - final user = state.user; - if (user == null) { - return _ProfileUserFallbackState( - isLoading: state.isLoading, - onRetryPressed: () => context.read().refresh(), - ); - } - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 28, 24, 132), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - AppStrings.profileGreeting(user.name), - style: textTheme.bodyMedium.copyWith( - fontSize: 18, - height: 27 / 18, - fontWeight: FontWeight.w500, - color: colorTheme.onSurface, - ), - ), - const SizedBox(height: 24), - UserSectionWidget( - user: user, - onEditPressed: () => _openEditProfileDialog(context, user), - onChangePasswordPressed: () => _openChangePasswordDialog(context), - ), - 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(), - ], - ), + 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(), + ], + ), + ); + }, + ), ); } } From ab9ada50ec7525df61c69a14ad089ea70888a5ba Mon Sep 17 00:00:00 2001 From: CowboyGH Date: Fri, 12 Jun 2026 15:23:59 +0700 Subject: [PATCH 8/8] docs: update CHANGELOG.md for profile decomposition --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ec747..ed518a1 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.