Skip to content
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions lib/core/di/di.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,10 +140,6 @@ Future<void> setupDI() async {
di<ProfileApiClient>(),
),
);
di.registerLazySingleton<ProfileRefreshCubit>(
() => ProfileRefreshCubit(),
dispose: (cubit) => cubit.close(),
);
di.registerLazySingleton<ProfileStatisticsRepository>(
() => ProfileStatisticsRepositoryImpl(
di<AppLogger>(),
Expand Down
15 changes: 15 additions & 0 deletions lib/core/network/api_paths.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
final data = json['data'];
if (data is! Map<String, dynamic> || !data.containsKey('id')) {
return const ProfileActiveSubscriptionResponseDto(subscription: null);
}
return ProfileActiveSubscriptionResponseDto(
subscription: ActiveProfileSubscriptionDto.fromJson(data),
);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) =>
_$ProfileHistoryResponseDtoFromJson(json);
}

/// Payload of the focused `/profile/history` response.
@JsonSerializable(createToJson: false)
class ProfileHistoryDataDto {
/// Completed workouts history.
@JsonKey(defaultValue: <ProfileWorkoutHistoryItemDto>[])
final List<ProfileWorkoutHistoryItemDto> workouts;

/// Completed test attempts history.
@JsonKey(defaultValue: <ProfileTestHistoryItemDto>[])
final List<ProfileTestHistoryItemDto> tests;

/// Creates an instance of [ProfileHistoryDataDto].
ProfileHistoryDataDto({required this.workouts, required this.tests});

/// Creates a [ProfileHistoryDataDto] from JSON.
factory ProfileHistoryDataDto.fromJson(Map<String, dynamic> json) =>
_$ProfileHistoryDataDtoFromJson(json);
}
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
final data = json['data'];
if (data is! Map<String, dynamic> || !data.containsKey('has_progress')) {
return const ProfilePhaseResponseDto(phase: null);
}
return ProfilePhaseResponseDto(phase: ProfilePhaseDto.fromJson(data));
}
}
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) =>
_$ProfileUserOnlyResponseDtoFromJson(json);
}
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
final data = json['data'];
if (data is! Map<String, dynamic> || !data.containsKey('goal')) {
return const ProfileUserParametersResponseDto(parameters: null);
}
return ProfileUserParametersResponseDto(
parameters: ProfileParametersInProfileDto.fromJson(data),
);
}
}
Original file line number Diff line number Diff line change
@@ -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,
);
}
Original file line number Diff line number Diff line change
@@ -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 = <ProfileWorkoutHistoryItemDto>[
...data.workouts,
]..sort((left, right) => _parseDate(right.completedAt).compareTo(_parseDate(left.completedAt)));
final sortedTests = <ProfileTestHistoryItemDto>[
...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);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
}
27 changes: 26 additions & 1 deletion lib/features/profile/data/remote/profile_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<ProfileUserResponseDto> getProfile();

/// Returns the focused authenticated user payload (id, name, email, avatar).
@GET(ApiPaths.profileUser)
Future<ProfileUserOnlyResponseDto> getUser();

/// Returns the focused active subscription snapshot.
@GET(ApiPaths.profileActiveSubscription)
Future<ProfileActiveSubscriptionResponseDto> getActiveSubscription();

/// Returns the focused current phase snapshot.
@GET(ApiPaths.profilePhase)
Future<ProfilePhaseResponseDto> getPhase();

/// Returns the focused user parameters display snapshot.
@GET(ApiPaths.profileUserParameters)
Future<ProfileUserParametersResponseDto> getProfileUserParameters();

/// Returns the focused profile history payload (workouts + tests arrays).
@GET(ApiPaths.profileHistory)
Future<ProfileHistoryResponseDto> getHistory();

/// Updates the authenticated user profile fields.
@PUT(ApiPaths.profile)
Future<void> updateProfile(@Body() UpdateProfileRequestDto request);
Expand Down
Loading
Loading