diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ebde1f0..daa26c49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Authenticated tests catalog screen for the `/tests` root tab, reusing the existing tests catalog slice and adding local search plus multi-select category filtering UI before navigating to the debug screen.
- Workout execution feature for authenticated users, including execution DTOs/mappers, repository, Cubit, fullscreen route, local rest countdown, dialogs, and the warmup/training UI backed by workout start/progression/complete endpoints.
- Authenticated test attempt flow for `/tests/attempt/:testingId`, including auth API client methods, repository wiring, fullscreen attempt route, and the attempt UI mirrored from the Fitness Start flow.
+- Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout.
+- Profile statistics section for the authenticated `/profile` tab, including dedicated statistics API client/repository, focused `/profile` history snapshot mapping, statistics Cubit/state flow, chart widgets, selectors, history dialog, and widget coverage for the integrated UI.
+- Profile current phase section for the authenticated `/profile` tab, reusing the bootstrap profile phase snapshot plus aggregate statistics frequency summary to render the read-only phase block without a standalone phase slice.
+- Introduce personal parameters section for the authenticated `/profile` tab, including canonical `user-parameters` read/update flow, editable profile form card, weekly-goal save support, and selective workouts overview refresh when goal, equipment, or level changes regenerate the personal plan.
+- Add profile bottom section for the authenticated `/profile` tab, including logout and delete-profile confirmation actions plus direct links to the bundled legal documents.
+- Profile subscription section for the authenticated `/profile` tab, including active and empty subscription states, catalog entrypoints, profile-local subscription card hydration by `subscriptionId`, cancel-subscription confirmation flow and profile page refresh after cancellation.
+- Profile saved cards section for the authenticated `/profile` tab, including dedicated cards API/repository flow, saved-cards list rendering, add-card dialog with manual card form, default-card command, delete-card confirmation, and local refresh after successful actions.
+- Authenticated subscriptions catalog screen, including dedicated subscriptions route, catalog Cubit, card UI with normalized remote images, and a profile CTA for opening available subscription plans.
+- Authenticated subscription details and payment flow, including a dedicated details route, catalog-backed item resolution, manual-card payment dialog, and redirect to `/profile` after successful purchase.
### Changed
@@ -28,12 +37,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Workouts overview and details app bars now reuse a dedicated `appBarTitle` text token instead of local per-page style overrides.
- Authenticated tests catalog cards now open the real test attempt flow instead of the debug screen.
- `TestingCatalogCard` now skips the extra spacing above category chips when a test has no categories.
+- The `/profile` root tab now renders the real user-section screen instead of the previous placeholder, reuses the authenticated session user as an initial seed, and keeps forgot-password routes reachable from the change-password dialog for authenticated users.
+- The `/profile` screen now bootstraps statistics and history from the authenticated profile flow: user bootstrap reuses `/api/profile`, charts switch between dedicated volume/frequency/trend endpoints, and the `История` modal reads active subscription plus the latest workout/test from the cached profile snapshot instead of issuing extra requests.
+- Profile statistics internals were reorganized into dedicated `profile/data/dto/stats` and `profile/presentation/widgets/stats` folders, while repository/cubit/widget tests were aligned with the new structure and shared fixtures.
+- Shared `OptionButton` now supports canonical `large` and `small` size presets, and the profile statistics plus history-tab controls use the compact 42px variant from the mockups.
+- Profile dialogs now support per-dialog content padding and optional barrier dismissal, allowing the statistics history modal to match the provided sheet behavior without affecting non-dismissible dialogs.
+- The debug route is now a static centered placeholder again and no longer owns a separate logout flow.
+- `AppCard` now supports an optional fixed height, allowing specialized screens like the subscriptions catalog to match exact card mockups without introducing a forked card component.
+- Subscriptions catalog cards now open a dedicated subscription details screen, and `AppInputField` now supports hidden labels for grouped payment-field layouts without requiring widget forks.
### Breaking
- Shared test-attempt transport DTOs were renamed from guest-prefixed names to neutral request/response models because the same payload shapes are now reused by both guest and authenticated flows.
- Test-attempt DI wiring now resolves separate guest and authenticated repository bindings while keeping the shared `TestAttemptCubit` and domain contract unchanged.
+### Fixed
+
+- Profile frequency statistics now handle weekly payloads without `short_label`, keep dense period charts readable across `week`, `month`, `3 months`, `6 months`, and `year`, and clarify non-daily X-axis scales with short `нед.` / `мес.` labels.
+- Profile statistics dropdown menus now size to their content and render above surrounding UI instead of being clipped by the statistics card or overlapping incorrectly with lower screen sections.
+- Profile history and statistics copy now matches the latest profile mockups more closely, including `Активность` / `Завершено` labels, the separate `Средняя оценка:` summary label, and shortened cross-year period labels like `25-26`.
+
## [0.3.1] - 2026-03-25
### Added
diff --git a/assets/icons/arrow_down.svg b/assets/icons/arrow_down.svg
new file mode 100644
index 00000000..e4dc4321
--- /dev/null
+++ b/assets/icons/arrow_down.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/card_big.svg b/assets/icons/card_big.svg
new file mode 100644
index 00000000..8b475f1c
--- /dev/null
+++ b/assets/icons/card_big.svg
@@ -0,0 +1,12 @@
+
diff --git a/assets/icons/card_small.svg b/assets/icons/card_small.svg
new file mode 100644
index 00000000..512161e0
--- /dev/null
+++ b/assets/icons/card_small.svg
@@ -0,0 +1,12 @@
+
diff --git a/assets/icons/close_variant.svg b/assets/icons/close_variant.svg
new file mode 100644
index 00000000..177d99d8
--- /dev/null
+++ b/assets/icons/close_variant.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/notification.svg b/assets/icons/notification.svg
new file mode 100644
index 00000000..435326d0
--- /dev/null
+++ b/assets/icons/notification.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg
new file mode 100644
index 00000000..1c8d3955
--- /dev/null
+++ b/assets/icons/plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/stats.svg b/assets/icons/stats.svg
new file mode 100644
index 00000000..6260cf89
--- /dev/null
+++ b/assets/icons/stats.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/line_variant.svg b/assets/images/line_variant.svg
new file mode 100644
index 00000000..ace8c267
--- /dev/null
+++ b/assets/images/line_variant.svg
@@ -0,0 +1,3 @@
+
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index e544677c..2da951e4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -5,6 +5,8 @@ PODS:
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
+ - image_picker_ios (0.0.1):
+ - Flutter
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
@@ -13,6 +15,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
+ - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
EXTERNAL SOURCES:
@@ -22,6 +25,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
+ image_picker_ios:
+ :path: ".symlinks/plugins/image_picker_ios/ios"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
@@ -29,6 +34,7 @@ SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
+ image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart
index 98ec585a..30bd2d30 100644
--- a/lib/core/constants/app_assets.dart
+++ b/lib/core/constants/app_assets.dart
@@ -14,13 +14,21 @@ abstract final class AppAssets {
static const iconSearch = 'search';
static const iconFilter = 'filter';
static const iconClose = 'close';
+ static const iconCloseVariant = 'close_variant';
+ static const iconNotification = 'notification';
static const iconBadFace = 'bad_face';
static const iconNormalFace = 'normal_face';
static const iconGoodFace = 'good_face';
+ static const iconArrowDown = 'arrow_down';
+ static const iconStats = 'stats';
+ static const iconCardBig = 'card_big';
+ static const iconCardSmall = 'card_small';
+ static const iconPlus = 'plus';
// Images.
static const imageFigure = 'figure';
static const imageLine = 'line';
+ static const imageLineVariant = 'line_variant';
// Legal documents.
static const legalPrivacyPolicy = 'assets/legal/privacy_policy.txt';
diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart
index 4dd24353..b40cdf2b 100644
--- a/lib/core/constants/app_strings.dart
+++ b/lib/core/constants/app_strings.dart
@@ -74,6 +74,7 @@ abstract final class AppStrings {
static const legalDataProcessingConsentTitle = 'Согласие на обработку персональных данных';
static const legalPublicOfferTitle = 'Публичная оферта';
static const legalDocumentLoadError = 'Не удалось загрузить документ.';
+ static const legalDataProcessingConsentProfileTitle = 'Пользовательское соглашение';
// Feedback dialogs.
static const feedbackErrorTitle = 'Что-то пошло не так';
@@ -207,6 +208,74 @@ abstract final class AppStrings {
'У вас уже есть начатая тренировка. Сначала завершите её, чтобы начать новую';
static const workoutsUnknown = 'Не удалось выполнить действие. Попробуйте снова';
+ // Cards.
+ static const cardsValidationFailed = 'Проверьте введенные данные и попробуйте снова';
+ static const cardsUnknown = 'Не удалось выполнить действие. Попробуйте снова';
+ static const cardsSectionTitle = 'Мои карты';
+ static const cardsLoadFailed = 'Не удалось загрузить карты';
+ static const cardsAddButton = 'Добавить карту';
+ static const cardsDefaultButton = 'Основная карта';
+ static const cardsMakeDefaultButton = 'Сделать карту основной';
+ static const cardsDeleteTitle = 'Удалить карту';
+ static const cardsDeleteDescription = 'Вы действительно хотите удалить карту?';
+ static const cardsDeleteConfirmButton = 'Подтвердить';
+ static const cardsAddLimitTitle = 'Нельзя добавить карту';
+ static const cardsAddLimitMessage = 'Можно сохранить не более 3 карт';
+
+ // Subscriptions catalog.
+ static const subscriptionsCatalogTitle = 'Подписки';
+ static const subscriptionsCatalogEmpty = 'Подписки не найдены';
+ static const subscriptionsCatalogLoadFailed = 'Не удалось загрузить подписки';
+ static const subscriptionsCatalogBenefitTests =
+ 'Расширенный набор тестов для качественной адаптации';
+ static const subscriptionsCatalogBenefitExercises = 'Расширенный набор упражнений';
+ static const subscriptionsCatalogRubles = 'рублей';
+ static const subscriptionsValidationFailed = 'Проверьте введенные данные и попробуйте снова';
+ static const subscriptionsNotFound = 'Подписка не найдена';
+ static const subscriptionsUnknown = 'Не удалось выполнить действие. Попробуйте снова';
+ static const subscriptionsDetailsLoadFailed = 'Не удалось загрузить подписку';
+ static const subscriptionsDetailsInfoPrefix = 'Подписка';
+ static const subscriptionsDetailsAccessDescription =
+ 'Полный доступ к персональной системе тренировок';
+ static const subscriptionsDetailsBuyButton = 'Оформить подписку';
+ static const subscriptionsDetailsAdvantagesTitle = 'Наши преимущества';
+ static const subscriptionsDetailsAdvantagesDescriptionPrefix = 'Не просто доступ к функциям, а ';
+ static const subscriptionsDetailsAdvantagesDescriptionHighlighted = 'индивидуальный';
+ static const subscriptionsDetailsAdvantagesDescriptionSuffix =
+ ' фитнес-маршрут, который строится на ваших уникальных данных и целях. Получите максимум от каждой тренировки.';
+ static const subscriptionsDetailsAdvantageLoadTitle = 'Умное управление нагрузкой';
+ static const subscriptionsDetailsAdvantageLoadSubtitle =
+ 'Технология, которая помогает повысить эффективность силовых упражнений и ускорить восстановление мышц';
+ static const subscriptionsDetailsAdvantageInjuryTitle = 'Профилактика травм';
+ static const subscriptionsDetailsAdvantageInjurySubtitle =
+ 'Рекомендации по разминке и заминке, подобранные под Ваш тип тренировок';
+ static const subscriptionsDetailsAdvantageDiagnosticsTitle = 'Расширенная диагностика';
+ static const subscriptionsDetailsAdvantageDiagnosticsSubtitle =
+ 'Набор из специализированных тестов (сила, выносливость, мобильность, тип телосложения) для точного определения Вашего уровня';
+ static const subscriptionsDetailsAdvantagePlanTitle = 'Персональный план';
+ static const subscriptionsDetailsAdvantagePlanSubtitle =
+ 'Автоматически сформированная программа тренировок, которая адаптируется по мере Вашего прогресса';
+ static const subscriptionsPaymentCardNumberLabel = 'Номер карты';
+ static const subscriptionsPaymentCardNumberHint = '#### #### #### ####';
+ static const subscriptionsPaymentCardNumberRequired = 'Введите номер карты';
+ static const subscriptionsPaymentCardNumberInvalid = 'Номер карты должен состоять из 16 цифр';
+ static const subscriptionsPaymentCardHolderLabel = 'Держатель карты';
+ static const subscriptionsPaymentCardHolderHint = 'IVAN IVANOV';
+ static const subscriptionsPaymentCardHolderRequired = 'Введите имя держателя карты';
+ static const subscriptionsPaymentCardHolderInvalid =
+ 'Имя держателя карты должно содержать только заглавные латинские буквы и пробелы';
+ static const subscriptionsPaymentExpiryLabel = 'Срок действия';
+ static const subscriptionsPaymentExpiryMonthHint = 'Месяц';
+ static const subscriptionsPaymentExpiryYearHint = 'Год';
+ static const subscriptionsPaymentYearLabel = 'Год';
+ static const subscriptionsPaymentCvvLabel = 'CVV';
+ static const subscriptionsPaymentCvvHint = '***';
+ static const subscriptionsPaymentRememberData = 'Запомнить мои данные';
+ static const subscriptionsPaymentPayButton = 'Оплатить';
+ static const subscriptionsPaymentPreviewExpiryLabel = 'Истекает';
+ static const subscriptionsPaymentPreviewExpiryMonthLabel = 'MM';
+ static const subscriptionsPaymentPreviewExpiryYearLabel = 'YY';
+
// Workout details.
static const workoutDetailsTitle = 'Тренировка';
static const workoutDetailsStartWarmupButton = 'Начать разминку';
@@ -242,6 +311,108 @@ abstract final class AppStrings {
static const workoutExecutionWeightSecondary = 'Пропустить';
static const workoutExecutionWeightInvalid = 'Введите корректный вес в килограммах';
+ // Profile.
+ static String profileGreeting(String name) => 'Здравствуйте, $name';
+ static const profileEditButton = 'Редактировать профиль';
+ static const profileChangePasswordButton = 'Сменить пароль';
+ static const profileChangePasswordTitle = 'Смена пароля';
+ static const profileOldPasswordLabel = 'Старый пароль';
+ static const profileNewPasswordLabel = 'Новый пароль';
+ static const profilePasswordConfirmationLabel = 'Подтверждение пароля';
+ static const profileUploadFileLabel = 'Загрузить файл:';
+ static const profileUploadFormat = 'формат';
+ static const profileUploadFormatPlaceholder = 'jpg формат';
+ static const profileEditEmailLabel = 'Введите email';
+ static const profileEditNameLabel = 'Введите имя';
+ static const profileSaveButton = 'Сохранить';
+ static const profileCancelButton = 'Отменить';
+ static const profileLoadFailed = 'Не удалось загрузить профиль';
+ static const profileValidationFailed = 'Проверьте введенные данные и попробуйте снова';
+ static const profileUpdateFailed = 'Не удалось обновить профиль. Попробуйте снова';
+ static const profileChangePasswordFailed = 'Не удалось сменить пароль. Попробуйте снова';
+ static const profileImagePickFailed = 'Не удалось выбрать изображение. Попробуйте снова';
+ static const profileCurrentPhaseTitle = 'Текущая фаза';
+ static const profileCurrentPhaseRecommendation = 'Вам рекомендуется тренироваться в неделю';
+ static const profileCurrentPhaseEmpty = 'У вас пока нет активной фазы';
+ static const profileCurrentPhaseLoadFailed = 'Не удалось загрузить текущую фазу';
+ static const profileParametersGoalLabel = 'Цель тренировок';
+ static const profileParametersLevelLabel = 'Уровень подготовки';
+ static const profileParametersWeeklyGoalLabel = 'Количество тренировок в неделю';
+ static const profileParametersSubmitButton = 'Подтвердить';
+ static const profileParametersLoadFailed = 'Не удалось загрузить параметры профиля';
+ static const profileParametersEquipmentUnavailable = 'Нет доступных вариантов';
+ static const profileParametersWeeklyGoalRequired = 'Введите количество тренировок в неделю';
+ static const profileParametersWeeklyGoalInvalid =
+ 'Введите корректное количество тренировок в неделю';
+ static const profileParametersWeeklyGoalRange = 'Допустимо от 1 до 7 тренировок в неделю';
+ static const profileBottomLogoutButton = 'Выйти';
+ static const profileBottomDeleteButton = 'Удалить профиль';
+ static const profileBottomLogoutTitle = 'Вы уверены, что хотите выйти?';
+ static const profileBottomDeleteTitle = 'Вы уверены, что хотите удалить профиль?';
+ static const profileBottomDeleteConfirm = 'Удалить';
+ static const profileSubscriptionsButton = 'Выбрать подписку';
+ static const profileSubscriptionActiveTitleAccent = 'Срок действия';
+ static const profileSubscriptionActiveTitleSuffix = ' вашей подписки';
+ static const profileSubscriptionExpiryPrefix = 'до';
+ static const profileSubscriptionRenewButton = 'Продлить подписку';
+ static const profileSubscriptionCancelButton = 'Отменить подписку';
+ static const profileSubscriptionCancelTitle = 'Отменить подписку?';
+ static const profileSubscriptionCancelDescription = 'Вы действительно хотите отменить подписку?';
+ static const profileSubscriptionCancelConfirm = 'Подтвердить';
+ static const profileSubscriptionEmptyTitle = 'У Вас нет активной подписки';
+ static const profileSubscriptionEmptySubtitle = 'Оформите подписку и получите:';
+ static const profileSubscriptionBenefitTrainings = 'Полный доступ к персональным тренировкам';
+ static const profileSubscriptionBenefitTestsAndExercises =
+ 'Расширенный набор тестов и упражнений';
+ static const profileSubscriptionCardLoadFailed = 'Не удалось загрузить подписку';
+ static const profileSubscriptionCardRetryButton = 'Повторить';
+ static const profileStatsTitle = 'Статистика тренировок пользователя';
+ static const profileStatsHistoryButton = 'История';
+ static const profileStatsVolumeMode = 'Объём';
+ static const profileStatsFrequencyMode = 'Частота';
+ static const profileStatsTrendMode = 'Тренд';
+ static const profileStatsCategoriesButton = 'Категории';
+ static const profileStatsExercisesButton = 'Упражнения';
+ static const profileStatsWorkoutsButton = 'Тренировки';
+ static const profileStatsVolumeChartTitle = 'Объём (кг)';
+ static const profileStatsFrequencyChartTitle = 'Частота тренировок';
+ static const profileStatsTrendChartTitle = 'Тренд по упражнениям';
+ static const profileStatsLoadFailed = 'Не удалось загрузить статистику';
+ static const profileStatsEmpty = 'Пока недостаточно данных для отображения статистики';
+ static const profileStatsHistoryTitle = 'История';
+ static const profileStatsHistoryCloseButton = 'Закрыть';
+ static const profileStatsHistorySubscriptionsTab = 'Подписки';
+ static const profileStatsHistoryWorkoutsTab = 'Тренировки';
+ static const profileStatsHistoryTestsTab = 'Тесты';
+ static const profileStatsHistorySubscriptionEmpty = 'Активная подписка отсутствует';
+ static const profileStatsHistoryWorkoutEmpty = 'Тренировки пока не завершены';
+ static const profileStatsHistoryTestEmpty = 'Тесты пока не пройдены';
+ static const profileStatsHistoryNameLabel = 'Название';
+ static const profileStatsHistoryPriceLabel = 'Стоимость';
+ static const profileStatsHistoryPeriodLabel = 'Активность';
+ static const profileStatsHistoryCompletedLabel = 'Завершено';
+ static const profileUnknown = 'Не удалось выполнить действие. Попробуйте снова';
+
+ static const profileStatsAverageScoreLabel = 'Средняя оценка:';
+
+ static String profileStatsAveragePerWeek(String value) => 'В среднем: $value / нед';
+
+ static String profileCurrentPhaseTrainingsPerWeek(int value) =>
+ 'Вы тренируетесь $value ${_profileCurrentPhaseTimesLabel(value)} в неделю.';
+
+ static String _profileCurrentPhaseTimesLabel(int value) {
+ final mod100 = value % 100;
+ if (mod100 >= 11 && mod100 <= 14) {
+ return 'раз';
+ }
+
+ return switch (value % 10) {
+ 1 => 'раз',
+ 2 || 3 || 4 => 'раза',
+ _ => 'раз',
+ };
+ }
+
/// Builds the increase-adjustment message for a new absolute weight value.
static String workoutExecutionAdjustmentIncrease(String weight) =>
'На следующем подходе увеличьте вес до $weight $workoutExecutionWeightHint';
@@ -263,7 +434,7 @@ abstract final class AppStrings {
}
// Debug screen.
- static const debugLogoutButton = 'Выйти';
+ static const debugScreenTitle = 'Debug Screen';
/// Root tabs.
static const testsTab = testsCatalogTitle;
diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart
index 98fdff9c..a32c3f49 100644
--- a/lib/core/di/di.dart
+++ b/lib/core/di/di.dart
@@ -13,10 +13,27 @@ import '../../features/auth/data/remote/auth_api_client.dart';
import '../../features/auth/data/repositories/auth_repository_impl.dart';
import '../../features/auth/domain/repositories/auth_repository.dart';
import '../../features/auth/presentation/cubits/auth_session_cubit.dart';
+import '../../features/cards/data/remote/cards_api_client.dart';
+import '../../features/cards/data/repositories/cards_repository_impl.dart';
+import '../../features/cards/domain/repositories/cards_repository.dart';
import '../../features/fitness_start/data/remote/fitness_start_api_client.dart';
import '../../features/fitness_start/data/repositories/fitness_start_repository_impl.dart';
import '../../features/fitness_start/domain/repositories/fitness_start_repository.dart';
import '../../features/offline/presentation/cubit/network_cubit.dart';
+import '../../features/profile/data/remote/profile_api_client.dart';
+import '../../features/profile/data/remote/profile_parameters_api_client.dart';
+import '../../features/profile/data/remote/profile_statistics_api_client.dart';
+import '../../features/profile/data/repositories/profile_parameters_repository_impl.dart';
+import '../../features/profile/data/repositories/profile_repository_impl.dart';
+import '../../features/profile/data/repositories/profile_statistics_repository_impl.dart';
+import '../../features/profile/domain/repositories/profile_parameters_repository.dart';
+import '../../features/profile/domain/repositories/profile_repository.dart';
+import '../../features/profile/domain/repositories/profile_statistics_repository.dart';
+import '../../features/profile/presentation/cubits/profile_refresh_cubit.dart';
+import '../../features/subscriptions/data/remote/subscriptions_api_client.dart';
+import '../../features/subscriptions/data/remote/subscription_payment_api_client.dart';
+import '../../features/subscriptions/data/repositories/subscriptions_repository_impl.dart';
+import '../../features/subscriptions/domain/repositories/subscriptions_repository.dart';
import '../../features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart';
import '../../features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart';
import '../../features/tests/attempt/domain/repositories/test_attempt_repository.dart';
@@ -30,6 +47,7 @@ import '../../features/workouts/execution/data/repositories/workout_execution_re
import '../../features/workouts/execution/domain/repositories/workout_execution_repository.dart';
import '../../features/workouts/overview/data/repositories/workouts_overview_repository_impl.dart';
import '../../features/workouts/overview/domain/repositories/workouts_overview_repository.dart';
+import '../../features/workouts/overview/presentation/cubits/workouts_overview_cubit.dart';
import '../network/api_paths.dart';
import '../network/dio_setup.dart';
import '../services/fitness_start_progress_storage/fitness_start_progress_storage.dart';
@@ -112,6 +130,53 @@ Future setupDI() async {
di(),
),
);
+ di.registerLazySingleton(() => ProfileApiClient(di()));
+ di.registerLazySingleton(
+ () => ProfileParametersApiClient(di()),
+ );
+ di.registerLazySingleton(
+ () => ProfileStatisticsApiClient(di()),
+ );
+ di.registerLazySingleton(
+ () => ProfileRepositoryImpl(
+ di(),
+ di(),
+ ),
+ );
+ di.registerLazySingleton(
+ () => ProfileRefreshCubit(),
+ dispose: (cubit) => cubit.close(),
+ );
+ di.registerLazySingleton(
+ () => ProfileStatisticsRepositoryImpl(
+ di(),
+ di(),
+ ),
+ );
+ di.registerLazySingleton(
+ () => ProfileParametersRepositoryImpl(
+ di(),
+ di(),
+ ),
+ );
+ di.registerLazySingleton(() => SubscriptionsApiClient(di()));
+ di.registerLazySingleton(() => CardsApiClient(di()));
+ di.registerLazySingleton(
+ () => SubscriptionPaymentApiClient(di()),
+ );
+ di.registerLazySingleton(
+ () => CardsRepositoryImpl(
+ di(),
+ di(),
+ ),
+ );
+ di.registerLazySingleton(
+ () => SubscriptionsRepositoryImpl(
+ di(),
+ di(),
+ di(),
+ ),
+ );
// Fitness Start
di.registerLazySingleton(() => FitnessStartApiClient(di()));
@@ -161,6 +226,10 @@ Future setupDI() async {
di(),
),
);
+ di.registerLazySingleton(
+ () => WorkoutsOverviewCubit(di()),
+ dispose: (cubit) => cubit.close(),
+ );
di.registerLazySingleton(
() => WorkoutDetailsRepositoryImpl(
di(),
diff --git a/lib/core/failures/feature/cards/cards_failure.dart b/lib/core/failures/feature/cards/cards_failure.dart
new file mode 100644
index 00000000..15175f16
--- /dev/null
+++ b/lib/core/failures/feature/cards/cards_failure.dart
@@ -0,0 +1,41 @@
+import '../../../constants/app_strings.dart';
+import '../../app_failure.dart';
+
+/// Cards application error.
+sealed class CardsFailure extends AppFailure {
+ /// Creates an instance of [CardsFailure].
+ const CardsFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Cards validation failed because the provided input is invalid.
+final class CardsValidationFailure extends CardsFailure {
+ /// Creates an instance of [CardsValidationFailure].
+ const CardsValidationFailure({
+ String message = AppStrings.cardsValidationFailed,
+ super.parentException,
+ super.stackTrace,
+ }) : super(message);
+}
+
+/// Cards request failed because of infrastructure or network conditions.
+final class CardsRequestFailure extends CardsFailure {
+ /// Creates an instance of [CardsRequestFailure].
+ const CardsRequestFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Unknown cards failure.
+final class UnknownCardsFailure extends CardsFailure {
+ /// Creates an instance of [UnknownCardsFailure].
+ const UnknownCardsFailure({
+ super.parentException,
+ super.stackTrace,
+ }) : super(AppStrings.cardsUnknown);
+}
diff --git a/lib/core/failures/feature/profile/profile_failure.dart b/lib/core/failures/feature/profile/profile_failure.dart
new file mode 100644
index 00000000..bbf8696f
--- /dev/null
+++ b/lib/core/failures/feature/profile/profile_failure.dart
@@ -0,0 +1,41 @@
+import '../../../constants/app_strings.dart';
+import '../../app_failure.dart';
+
+/// Profile application error.
+sealed class ProfileFailure extends AppFailure {
+ /// Creates an instance of [ProfileFailure].
+ const ProfileFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Profile validation failed because the provided input is invalid.
+final class ProfileValidationFailure extends ProfileFailure {
+ /// Creates an instance of [ProfileValidationFailure].
+ const ProfileValidationFailure({
+ String message = AppStrings.profileValidationFailed,
+ super.parentException,
+ super.stackTrace,
+ }) : super(message);
+}
+
+/// Profile request failed because of infrastructure or network conditions.
+final class ProfileRequestFailure extends ProfileFailure {
+ /// Creates an instance of [ProfileRequestFailure].
+ const ProfileRequestFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Unknown profile failure.
+final class UnknownProfileFailure extends ProfileFailure {
+ /// Creates an instance of [UnknownProfileFailure].
+ const UnknownProfileFailure({
+ super.parentException,
+ super.stackTrace,
+ }) : super(AppStrings.profileUnknown);
+}
diff --git a/lib/core/failures/feature/subscriptions/subscriptions_failure.dart b/lib/core/failures/feature/subscriptions/subscriptions_failure.dart
new file mode 100644
index 00000000..b37ef64e
--- /dev/null
+++ b/lib/core/failures/feature/subscriptions/subscriptions_failure.dart
@@ -0,0 +1,47 @@
+import '../../../constants/app_strings.dart';
+import '../../app_failure.dart';
+
+/// Subscriptions application error.
+sealed class SubscriptionsFailure extends AppFailure {
+ /// Creates an instance of [SubscriptionsFailure].
+ const SubscriptionsFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Subscriptions validation failed because the provided input is invalid.
+final class SubscriptionsValidationFailure extends SubscriptionsFailure {
+ /// Creates an instance of [SubscriptionsValidationFailure].
+ const SubscriptionsValidationFailure({
+ String message = AppStrings.subscriptionsValidationFailed,
+ super.parentException,
+ super.stackTrace,
+ }) : super(message);
+}
+
+/// Subscriptions request failed because of infrastructure or network conditions.
+final class SubscriptionsRequestFailure extends SubscriptionsFailure {
+ /// Creates an instance of [SubscriptionsRequestFailure].
+ const SubscriptionsRequestFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Subscription could not be found in the active catalog payload.
+final class SubscriptionsNotFoundFailure extends SubscriptionsFailure {
+ /// Creates an instance of [SubscriptionsNotFoundFailure].
+ const SubscriptionsNotFoundFailure() : super(AppStrings.subscriptionsNotFound);
+}
+
+/// Unknown subscriptions failure.
+final class UnknownSubscriptionsFailure extends SubscriptionsFailure {
+ /// Creates an instance of [UnknownSubscriptionsFailure].
+ const UnknownSubscriptionsFailure({
+ super.parentException,
+ super.stackTrace,
+ }) : super(AppStrings.subscriptionsUnknown);
+}
diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart
index 06fcd002..c38e9903 100644
--- a/lib/core/network/api_paths.dart
+++ b/lib/core/network/api_paths.dart
@@ -41,9 +41,39 @@ abstract class ApiPaths {
/// The endpoint for the current user profile.
static const String me = '${apiPrefix}me';
+ /// The endpoint for the authenticated profile payload.
+ static const String profile = '${apiPrefix}profile';
+
+ /// The endpoint for changing the authenticated user password.
+ static const String profileChangePassword = '$profile/change-password';
+
+ /// The endpoint for uploading or deleting the authenticated user avatar.
+ static const String profileAvatar = '$profile/avatar';
+
+ /// The endpoint prefix for profile statistics.
+ static const String profileStatistics = '$profile/statistics';
+
+ /// The endpoint for volume statistics.
+ static const String profileStatisticsVolume = '$profileStatistics/volume';
+
+ /// The endpoint for trend statistics.
+ static const String profileStatisticsTrend = '$profileStatistics/trend';
+
+ /// The endpoint for frequency statistics.
+ static const String profileStatisticsFrequency = '$profileStatistics/frequency';
+
+ /// The endpoint for profile statistics exercises selector.
+ static const String profileStatisticsExercises = '$profileStatistics/exercises';
+
+ /// The endpoint for profile statistics workouts selector.
+ static const String profileStatisticsWorkouts = '$profileStatistics/workouts';
+
/// The endpoint for all user-parameters references.
static const String userParameterReferences = '${apiPrefix}user-parameters/references';
+ /// The endpoint for the current authenticated user parameters.
+ static const String userParameterMe = '${apiPrefix}user-parameters/me';
+
/// The endpoint for saving user training goal.
static const String userParameterGoal = '${apiPrefix}user-parameters/goal';
@@ -53,6 +83,9 @@ abstract class ApiPaths {
/// The endpoint for saving user fitness level.
static const String userParameterLevel = '${apiPrefix}user-parameters/level';
+ /// The endpoint for updating the current weekly training goal.
+ static const String userWeeklyGoal = '${apiPrefix}user/weekly-goal';
+
/// The endpoint for all active testings.
static const String testings = '${apiPrefix}testings';
@@ -71,6 +104,24 @@ abstract class ApiPaths {
/// The endpoint for the current user workouts overview.
static const String workouts = '${apiPrefix}workouts';
+ /// The endpoint for the subscriptions catalog.
+ static const String subscriptions = '${apiPrefix}subscriptions';
+
+ /// The endpoint for cancelling the active subscription.
+ static const String cancelSubscription = '${apiPrefix}cancel-subscription';
+
+ /// The endpoint for paying for a subscription.
+ static const String paymentSubscription = '${apiPrefix}payment/subscription';
+
+ /// The endpoint for saved cards list.
+ static const String paymentCards = '${apiPrefix}payment/cards';
+
+ /// The endpoint for saving a new card.
+ static const String paymentCardsSave = '$paymentCards/save';
+
+ /// The endpoint prefix for card default command.
+ static const String paymentCardsDefault = '$paymentCards/{cardId}/default';
+
/// The endpoint for starting a workout.
static const String workoutsStart = '$workouts/start';
diff --git a/lib/core/network/mappers/image_url_mapper.dart b/lib/core/network/mappers/image_url_mapper.dart
new file mode 100644
index 00000000..a9dbd645
--- /dev/null
+++ b/lib/core/network/mappers/image_url_mapper.dart
@@ -0,0 +1,16 @@
+import '../api_paths.dart';
+
+/// Normalizes relative backend image paths into absolute URLs.
+String normalizeBackendImageUrl(String rawImage) {
+ final image = rawImage.trim();
+ if (image.isEmpty) return '';
+ if (image.startsWith('http://') || image.startsWith('https://')) {
+ return image;
+ }
+
+ final normalizedPath = image.replaceFirst(RegExp(r'^/+'), '');
+ final storagePath = normalizedPath.startsWith('storage/')
+ ? normalizedPath
+ : 'storage/$normalizedPath';
+ return Uri.parse(ApiPaths.baseUrl).resolve(storagePath).toString();
+}
diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart
index d7d58194..c0330b0f 100644
--- a/lib/core/router/router.dart
+++ b/lib/core/router/router.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
-import '../../core/constants/app_strings.dart';
import '../../features/auth/presentation/cubits/auth_session_cubit.dart';
import '../../features/auth/presentation/pages/forgot_password_page_builder.dart';
import '../../features/auth/presentation/pages/legal_document_page.dart';
@@ -21,16 +20,18 @@ import '../../features/fitness_start/presentation/pages/fitness_start_test_attem
import '../../features/fitness_start/presentation/pages/fitness_start_tests_page_builder.dart';
import '../../features/offline/presentation/cubit/network_cubit.dart';
import '../../features/offline/presentation/pages/offline_page.dart';
+import '../../features/profile/presentation/pages/profile_page_builder.dart';
import '../../features/root/presentation/pages/root_screen.dart';
import '../../features/splash/presentation/pages/splash_page.dart';
+import '../../features/subscriptions/domain/entities/subscription_catalog_item.dart';
+import '../../features/subscriptions/presentation/pages/subscriptions_catalog_page_builder.dart';
+import '../../features/subscriptions/presentation/pages/subscriptions_details_page_builder.dart';
import '../../features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart';
import '../../features/tests/catalog/presentation/pages/tests_catalog_page_builder.dart';
import '../../features/workouts/details/presentation/pages/workout_details_page_builder.dart';
import '../../features/workouts/execution/domain/entities/workout_execution_entry_mode.dart';
import '../../features/workouts/execution/presentation/pages/workout_execution_page_builder.dart';
import '../../features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart';
-import '../../uikit/themes/colors/app_color_theme.dart';
-import '../../uikit/themes/text/app_text_theme.dart';
import '../di/di.dart';
import '../utils/analytics/app_analytics.dart';
import 'analytics_route_observer.dart';
@@ -60,6 +61,11 @@ bool _isGuestCompletedAllowedPath(String path) =>
path == AppRoutePaths.resetPasswordPath ||
path == AppRoutePaths.legalDocumentPath;
+bool _isAuthenticatedAllowedAuthPath(String path) =>
+ path == AppRoutePaths.forgotPasswordPath ||
+ path == AppRoutePaths.verifyResetCodePath ||
+ path == AppRoutePaths.resetPasswordPath;
+
/// Determines the redirect path based on the current [networkState], [authState] and [state].
String? _redirect(
NetworkState networkState,
@@ -105,6 +111,7 @@ String? _redirectByAuth(
final isSplashScreen = state.matchedLocation == AppRoutePaths.splashPath;
final isAuthScreen = state.matchedLocation.startsWith(AppRoutePaths.authPrefix);
final isFitnessStartScreen = state.matchedLocation.startsWith(AppRoutePaths.fitnessStartPrefix);
+ final isLegalDocument = state.matchedLocation == AppRoutePaths.legalDocumentPath;
return authState.when(
initial: () {
if (isSplashScreen) return null;
@@ -119,6 +126,7 @@ String? _redirectByAuth(
if (state.matchedLocation == AppRoutePaths.signUpPath) {
return AppRoutePaths.fitnessStartQuizPath;
}
+ if (isLegalDocument) return null;
if (isAuthScreen) return null;
return AppRoutePaths.signInPath;
},
@@ -142,6 +150,8 @@ String? _redirectByAuth(
state.matchedLocation == AppRoutePaths.debugPath) {
return null;
}
+ if (isLegalDocument) return null;
+ if (_isAuthenticatedAllowedAuthPath(state.matchedLocation)) return null;
if (isSplashScreen || isAuthScreen || isFitnessStartScreen) {
return AppRoutePaths.workoutsPath;
}
@@ -152,6 +162,7 @@ String? _redirectByAuth(
if (state.matchedLocation == AppRoutePaths.signUpPath) {
return AppRoutePaths.fitnessStartQuizPath;
}
+ if (isLegalDocument) return null;
if (isAuthScreen) return null;
return AppRoutePaths.signInPath;
},
@@ -245,9 +256,7 @@ final router = GoRouter(
routes: [
GoRoute(
path: AppRoutePaths.profilePath,
- builder: (_, _) => const _RootPlaceholderScreen(
- screenName: AppStrings.profileTab,
- ),
+ builder: (_, _) => const ProfilePageBuilder(),
),
],
),
@@ -265,6 +274,29 @@ final router = GoRouter(
path: AppRoutePaths.offlinePath,
builder: (context, state) => const OfflinePage(),
),
+ GoRoute(
+ path: AppRoutePaths.subscriptionsCatalogPath,
+ builder: (_, _) => const SubscriptionsCatalogPageBuilder(),
+ routes: [
+ GoRoute(
+ path: 'details/:subscriptionId',
+ redirect: (_, state) {
+ final rawSubscriptionId = state.pathParameters['subscriptionId'];
+ final subscriptionId = int.tryParse(rawSubscriptionId ?? '');
+ if (subscriptionId == null || subscriptionId <= 0) {
+ return AppRoutePaths.subscriptionsCatalogPath;
+ }
+ return null;
+ },
+ builder: (_, state) => SubscriptionsDetailsPageBuilder(
+ subscriptionId: int.parse(state.pathParameters['subscriptionId']!),
+ seedItem: state.extra is SubscriptionCatalogItem
+ ? state.extra as SubscriptionCatalogItem
+ : null,
+ ),
+ ),
+ ],
+ ),
GoRoute(
path: AppRoutePaths.signInPath,
builder: (_, _) => const SignInPageBuilder(),
@@ -388,23 +420,3 @@ class CombinedRouterRefreshListenable extends ChangeNotifier {
super.dispose();
}
}
-
-final class _RootPlaceholderScreen extends StatelessWidget {
- final String screenName;
-
- const _RootPlaceholderScreen({required this.screenName});
-
- @override
- Widget build(BuildContext context) {
- final textTheme = AppTextTheme.of(context);
- final colorTheme = AppColorTheme.of(context);
- return Scaffold(
- body: Center(
- child: Text(
- screenName,
- style: textTheme.title.copyWith(color: colorTheme.onSurface),
- ),
- ),
- );
- }
-}
diff --git a/lib/core/router/router_paths.dart b/lib/core/router/router_paths.dart
index 2e2bc767..1cace498 100644
--- a/lib/core/router/router_paths.dart
+++ b/lib/core/router/router_paths.dart
@@ -31,7 +31,7 @@ abstract class AppRoutePaths {
static const signUpPath = '$authPrefix/sign-up';
/// Route path for the legal-document page.
- static const legalDocumentPath = '$authPrefix/legal-document';
+ static const legalDocumentPath = '/legal-document';
/// Route path for the forgot-password page.
static const forgotPasswordPath = '$authPrefix/forgot-password';
@@ -67,6 +67,19 @@ abstract class AppRoutePaths {
/// Route path for workouts overview.
static const workoutsPath = '/workouts';
+ /// Route path for subscriptions catalog.
+ static const subscriptionsCatalogPath = '/subscriptions';
+
+ /// Base route path for a concrete subscriptions details screen.
+ static const subscriptionsDetailsBasePath = '$subscriptionsCatalogPath/details';
+
+ /// Route path pattern for a concrete subscription details screen.
+ static const subscriptionsDetailsPath = '$subscriptionsDetailsBasePath/:subscriptionId';
+
+ /// Builds the concrete route path for subscription details by [subscriptionId].
+ static String subscriptionsDetailsConcretePath(int subscriptionId) =>
+ '$subscriptionsDetailsBasePath/$subscriptionId';
+
/// Base route path for a concrete workout details screen.
static const workoutDetailsBasePath = '$workoutsPath/details';
diff --git a/lib/features/auth/presentation/cubits/auth_session_cubit.dart b/lib/features/auth/presentation/cubits/auth_session_cubit.dart
index 8c4fe84b..5998aa71 100644
--- a/lib/features/auth/presentation/cubits/auth_session_cubit.dart
+++ b/lib/features/auth/presentation/cubits/auth_session_cubit.dart
@@ -248,6 +248,17 @@ final class AuthSessionCubit extends Cubit {
emit(AuthSessionState.authenticated(user));
}
+ /// Updates the authenticated user payload without changing the auth mode.
+ void updateAuthenticatedUser(User user) {
+ final canUpdate = state.maybeWhen(
+ authenticated: (_) => true,
+ orElse: () => false,
+ );
+ if (!canUpdate || isClosed) return;
+
+ emit(AuthSessionState.authenticated(user));
+ }
+
/// Marks the current session as unauthenticated.
Future clearSession() async {
final isCleared = await _clearGuestDataSafely();
@@ -259,4 +270,10 @@ final class AuthSessionCubit extends Cubit {
emit(const AuthSessionState.unauthenticated());
}
}
+
+ /// Fully signs out the authenticated user from the current device.
+ Future signOut() async {
+ await _clearTokenSafely();
+ await clearSession();
+ }
}
diff --git a/lib/features/cards/data/dto/save_card_request_dto.dart b/lib/features/cards/data/dto/save_card_request_dto.dart
new file mode 100644
index 00000000..f23d3a01
--- /dev/null
+++ b/lib/features/cards/data/dto/save_card_request_dto.dart
@@ -0,0 +1,34 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'save_card_request_dto.g.dart';
+
+/// DTO for saving a new payment card.
+@JsonSerializable(createFactory: false)
+class SaveCardRequestDto {
+ /// Manual card number.
+ @JsonKey(name: 'card_number')
+ final String cardNumber;
+
+ /// Manual card holder name.
+ @JsonKey(name: 'card_holder')
+ final String cardHolder;
+
+ /// Card expiry month.
+ @JsonKey(name: 'expiry_month')
+ final String expiryMonth;
+
+ /// Card expiry year.
+ @JsonKey(name: 'expiry_year')
+ final String expiryYear;
+
+ /// Creates an instance of [SaveCardRequestDto].
+ SaveCardRequestDto({
+ required this.cardNumber,
+ required this.cardHolder,
+ required this.expiryMonth,
+ required this.expiryYear,
+ });
+
+ /// Converts [SaveCardRequestDto] to JSON.
+ Map toJson() => _$SaveCardRequestDtoToJson(this);
+}
diff --git a/lib/features/cards/data/dto/saved_card_dto.dart b/lib/features/cards/data/dto/saved_card_dto.dart
new file mode 100644
index 00000000..91edf3db
--- /dev/null
+++ b/lib/features/cards/data/dto/saved_card_dto.dart
@@ -0,0 +1,43 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'saved_card_dto.g.dart';
+
+/// DTO for a saved payment card.
+@JsonSerializable(createToJson: false)
+class SavedCardDto {
+ /// Card identifier.
+ final int id;
+
+ /// Card holder name.
+ @JsonKey(name: 'card_holder')
+ final String cardHolder;
+
+ /// Last four digits of the card number.
+ @JsonKey(name: 'card_last_four')
+ final String cardLastFour;
+
+ /// Expiry month.
+ @JsonKey(name: 'expiry_month')
+ final String expiryMonth;
+
+ /// Expiry year.
+ @JsonKey(name: 'expiry_year')
+ final String expiryYear;
+
+ /// Whether the card is default.
+ @JsonKey(name: 'is_default')
+ final bool isDefault;
+
+ /// Creates an instance of [SavedCardDto].
+ SavedCardDto({
+ required this.id,
+ required this.cardHolder,
+ required this.cardLastFour,
+ required this.expiryMonth,
+ required this.expiryYear,
+ required this.isDefault,
+ });
+
+ /// Creates a [SavedCardDto] from JSON.
+ factory SavedCardDto.fromJson(Map json) => _$SavedCardDtoFromJson(json);
+}
diff --git a/lib/features/cards/data/dto/saved_cards_response_dto.dart b/lib/features/cards/data/dto/saved_cards_response_dto.dart
new file mode 100644
index 00000000..57aa9edb
--- /dev/null
+++ b/lib/features/cards/data/dto/saved_cards_response_dto.dart
@@ -0,0 +1,19 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import 'saved_card_dto.dart';
+
+part 'saved_cards_response_dto.g.dart';
+
+/// DTO for saved cards response envelope.
+@JsonSerializable(createToJson: false)
+class SavedCardsResponseDto {
+ /// Cards payload.
+ final List data;
+
+ /// Creates an instance of [SavedCardsResponseDto].
+ SavedCardsResponseDto({required this.data});
+
+ /// Creates a [SavedCardsResponseDto] from JSON.
+ factory SavedCardsResponseDto.fromJson(Map json) =>
+ _$SavedCardsResponseDtoFromJson(json);
+}
diff --git a/lib/features/cards/data/mappers/cards_failure_mapper.dart b/lib/features/cards/data/mappers/cards_failure_mapper.dart
new file mode 100644
index 00000000..ef359529
--- /dev/null
+++ b/lib/features/cards/data/mappers/cards_failure_mapper.dart
@@ -0,0 +1,48 @@
+import '../../../../core/failures/feature/cards/cards_failure.dart';
+import '../../../../core/failures/helpers/validation_message_builder.dart';
+import '../../../../core/failures/network/network_failure.dart';
+
+/// Extension to map [NetworkFailure] into [CardsFailure].
+extension CardsFailureMapper on NetworkFailure {
+ /// Maps a [NetworkFailure] into a cards-specific failure.
+ CardsFailure toCardsFailure() {
+ final fieldErrors = switch (this) {
+ ValidationFailure(:final errors) => errors,
+ _ => const >{},
+ };
+ final validationMessage = buildValidationMessage(
+ fieldErrors,
+ fallbackMessage: const CardsValidationFailure().message,
+ );
+
+ switch (code) {
+ case 'validation_failed':
+ return CardsValidationFailure(
+ message: validationMessage,
+ parentException: parentException,
+ stackTrace: stackTrace,
+ );
+ }
+
+ return switch (this) {
+ NoNetworkFailure() ||
+ ConnectionTimeoutFailure() ||
+ BadRequestFailure() ||
+ UnauthorizedFailure() ||
+ ForbiddenFailure() ||
+ NotFoundFailure() ||
+ ConflictFailure() ||
+ RateLimitedFailure() ||
+ ServerErrorFailure() ||
+ UnknownNetworkFailure() => CardsRequestFailure(
+ message,
+ parentException: parentException,
+ stackTrace: stackTrace,
+ ),
+ _ => UnknownCardsFailure(
+ parentException: parentException,
+ stackTrace: stackTrace,
+ ),
+ };
+ }
+}
diff --git a/lib/features/cards/data/mappers/saved_card_mapper.dart b/lib/features/cards/data/mappers/saved_card_mapper.dart
new file mode 100644
index 00000000..c29de2df
--- /dev/null
+++ b/lib/features/cards/data/mappers/saved_card_mapper.dart
@@ -0,0 +1,15 @@
+import '../../domain/entities/saved_card.dart';
+import '../dto/saved_card_dto.dart';
+
+/// Maps [SavedCardDto] into the cards domain layer.
+extension SavedCardMapper on SavedCardDto {
+ /// Converts [SavedCardDto] to [SavedCard].
+ SavedCard toEntity() => SavedCard(
+ id: id,
+ holderName: cardHolder,
+ lastFour: cardLastFour,
+ expiryMonth: expiryMonth,
+ expiryYear: expiryYear,
+ isDefault: isDefault,
+ );
+}
diff --git a/lib/features/cards/data/remote/cards_api_client.dart b/lib/features/cards/data/remote/cards_api_client.dart
new file mode 100644
index 00000000..b2989526
--- /dev/null
+++ b/lib/features/cards/data/remote/cards_api_client.dart
@@ -0,0 +1,31 @@
+import 'package:dio/dio.dart';
+import 'package:retrofit/retrofit.dart';
+
+import '../../../../core/network/api_paths.dart';
+import '../dto/save_card_request_dto.dart';
+import '../dto/saved_cards_response_dto.dart';
+
+part 'cards_api_client.g.dart';
+
+/// Retrofit API client for saved cards requests and commands.
+@RestApi()
+abstract class CardsApiClient {
+ /// Creates an instance of [CardsApiClient].
+ factory CardsApiClient(Dio dio, {String? baseUrl}) = _CardsApiClient;
+
+ /// Returns all saved cards for the authenticated user.
+ @GET(ApiPaths.paymentCards)
+ Future getCards();
+
+ /// Saves a new card.
+ @POST(ApiPaths.paymentCardsSave)
+ Future saveCard(@Body() SaveCardRequestDto request);
+
+ /// Marks a card as default.
+ @POST('${ApiPaths.paymentCards}/{cardId}/default')
+ Future setDefaultCard(@Path('cardId') int cardId);
+
+ /// Deletes a saved card.
+ @DELETE('${ApiPaths.paymentCards}/{cardId}')
+ Future deleteCard(@Path('cardId') int cardId);
+}
diff --git a/lib/features/cards/data/repositories/cards_repository_impl.dart b/lib/features/cards/data/repositories/cards_repository_impl.dart
new file mode 100644
index 00000000..908fcdf8
--- /dev/null
+++ b/lib/features/cards/data/repositories/cards_repository_impl.dart
@@ -0,0 +1,106 @@
+import 'package:dio/dio.dart';
+
+import '../../../../core/failures/feature/cards/cards_failure.dart';
+import '../../../../core/network/mappers/dio_exception_mapper.dart';
+import '../../../../core/result/result.dart';
+import '../../../../core/utils/logger/app_logger.dart';
+import '../../domain/entities/save_card_payload.dart';
+import '../../domain/entities/saved_card.dart';
+import '../../domain/repositories/cards_repository.dart';
+import '../dto/save_card_request_dto.dart';
+import '../mappers/cards_failure_mapper.dart';
+import '../mappers/saved_card_mapper.dart';
+import '../remote/cards_api_client.dart';
+
+/// Implementation of [CardsRepository].
+final class CardsRepositoryImpl implements CardsRepository {
+ final AppLogger _logger;
+ final CardsApiClient _apiClient;
+
+ /// Creates an instance of [CardsRepositoryImpl].
+ CardsRepositoryImpl(this._logger, this._apiClient);
+
+ @override
+ Future, CardsFailure>> getCards() async {
+ try {
+ final response = await _apiClient.getCards();
+ final defaultCards = [];
+ final regularCards = [];
+
+ for (final item in response.data.map((item) => item.toEntity())) {
+ if (item.isDefault) {
+ defaultCards.add(item);
+ } else {
+ regularCards.add(item);
+ }
+ }
+
+ return Result.success([...defaultCards, ...regularCards]);
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toCardsFailure());
+ } catch (e, s) {
+ _logger.e('GetCards failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownCardsFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+
+ @override
+ Future> saveCard({
+ required SaveCardPayload payload,
+ }) async {
+ try {
+ await _apiClient.saveCard(
+ SaveCardRequestDto(
+ cardNumber: payload.cardNumber,
+ cardHolder: payload.cardHolder,
+ expiryMonth: payload.expiryMonth,
+ expiryYear: payload.expiryYear,
+ ),
+ );
+ return const Result.success(null);
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toCardsFailure());
+ } catch (e, s) {
+ _logger.e('SaveCard failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownCardsFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+
+ @override
+ Future> setDefaultCard(int cardId) async {
+ try {
+ await _apiClient.setDefaultCard(cardId);
+ return const Result.success(null);
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toCardsFailure());
+ } catch (e, s) {
+ _logger.e('SetDefaultCard failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownCardsFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+
+ @override
+ Future> deleteCard(int cardId) async {
+ try {
+ await _apiClient.deleteCard(cardId);
+ return const Result.success(null);
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toCardsFailure());
+ } catch (e, s) {
+ _logger.e('DeleteCard failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownCardsFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+}
diff --git a/lib/features/cards/domain/entities/save_card_payload.dart b/lib/features/cards/domain/entities/save_card_payload.dart
new file mode 100644
index 00000000..ec4f394a
--- /dev/null
+++ b/lib/features/cards/domain/entities/save_card_payload.dart
@@ -0,0 +1,32 @@
+import 'package:equatable/equatable.dart';
+
+/// Typed payload submitted from the save-card dialog.
+final class SaveCardPayload extends Equatable {
+ /// Card number without spaces.
+ final String cardNumber;
+
+ /// Card holder name.
+ final String cardHolder;
+
+ /// Expiry month.
+ final String expiryMonth;
+
+ /// Expiry year.
+ final String expiryYear;
+
+ /// Creates an instance of [SaveCardPayload].
+ const SaveCardPayload({
+ required this.cardNumber,
+ required this.cardHolder,
+ required this.expiryMonth,
+ required this.expiryYear,
+ });
+
+ @override
+ List