From 1fc9eda5242c465d7c30346132427dadd09ad0ba Mon Sep 17 00:00:00 2001
From: Ryan Delaney <145113692+CowboyGH@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:44:42 +0700
Subject: [PATCH 01/13] feat(profile): implement profile user section with
dialogs (#52)
* build(deps): add image_picker for profile avatar flow
* feat(profile): add profile api contract
* feat(profile): add profile repository layer
* test(profile): add repository coverage
* feat(profile): add profile presentation cubits
* test(profile): add cubit coverage
* feat(profile): add profile user section ui
* docs(changelog): document profile user section
* chore(deps): update file_picker to the latest version
* fix(profile): map auth failures as request failures
* fix(profile): harden user section interactions
---
CHANGELOG.md | 2 +
assets/icons/notification.svg | 3 +
ios/Podfile.lock | 6 +
lib/core/constants/app_assets.dart | 1 +
lib/core/constants/app_strings.dart | 21 ++
lib/core/di/di.dart | 10 +
.../feature/profile/profile_failure.dart | 41 +++
lib/core/network/api_paths.dart | 9 +
lib/core/router/router.dart | 34 +-
.../cubits/auth_session_cubit.dart | 11 +
.../data/dto/change_password_request_dto.dart | 29 ++
.../data/dto/profile_user_data_dto.dart | 21 ++
.../profile/data/dto/profile_user_dto.dart | 41 +++
.../data/dto/profile_user_response_dto.dart | 21 ++
.../data/dto/update_profile_request_dto.dart | 22 ++
.../data/mappers/profile_failure_mapper.dart | 52 +++
.../mappers/profile_user_entity_mapper.dart | 13 +
.../data/remote/profile_api_client.dart | 33 ++
.../repositories/profile_repository_impl.dart | 113 ++++++
.../repositories/profile_repository.dart | 24 ++
.../cubits/change_password_cubit.dart | 46 +++
.../cubits/change_password_state.dart | 17 +
.../cubits/profile_user_cubit.dart | 66 ++++
.../cubits/profile_user_state.dart | 12 +
.../cubits/update_profile_cubit.dart | 49 +++
.../cubits/update_profile_state.dart | 17 +
.../presentation/pages/profile_page.dart | 144 ++++++++
.../pages/profile_page_builder.dart | 32 ++
.../widgets/change_password_dialog.dart | 177 ++++++++++
.../widgets/edit_profile_dialog.dart | 291 ++++++++++++++++
.../widgets/profile_dialog_shell.dart | 58 ++++
.../widgets/user_section_widget.dart | 87 +++++
pubspec.lock | 112 ++++++
pubspec.yaml | 1 +
.../cubits/auth_session_cubit_test.dart | 32 ++
.../mappers/profile_failure_mapper_test.dart | 74 ++++
.../profile_repository_impl_test.dart | 326 ++++++++++++++++++
.../cubits/change_password_cubit_test.dart | 117 +++++++
.../cubits/profile_user_cubit_test.dart | 111 ++++++
.../cubits/update_profile_cubit_test.dart | 136 ++++++++
.../profile/support/profile_dto_fixtures.dart | 78 +++++
41 files changed, 2464 insertions(+), 26 deletions(-)
create mode 100644 assets/icons/notification.svg
create mode 100644 lib/core/failures/feature/profile/profile_failure.dart
create mode 100644 lib/features/profile/data/dto/change_password_request_dto.dart
create mode 100644 lib/features/profile/data/dto/profile_user_data_dto.dart
create mode 100644 lib/features/profile/data/dto/profile_user_dto.dart
create mode 100644 lib/features/profile/data/dto/profile_user_response_dto.dart
create mode 100644 lib/features/profile/data/dto/update_profile_request_dto.dart
create mode 100644 lib/features/profile/data/mappers/profile_failure_mapper.dart
create mode 100644 lib/features/profile/data/mappers/profile_user_entity_mapper.dart
create mode 100644 lib/features/profile/data/remote/profile_api_client.dart
create mode 100644 lib/features/profile/data/repositories/profile_repository_impl.dart
create mode 100644 lib/features/profile/domain/repositories/profile_repository.dart
create mode 100644 lib/features/profile/presentation/cubits/change_password_cubit.dart
create mode 100644 lib/features/profile/presentation/cubits/change_password_state.dart
create mode 100644 lib/features/profile/presentation/cubits/profile_user_cubit.dart
create mode 100644 lib/features/profile/presentation/cubits/profile_user_state.dart
create mode 100644 lib/features/profile/presentation/cubits/update_profile_cubit.dart
create mode 100644 lib/features/profile/presentation/cubits/update_profile_state.dart
create mode 100644 lib/features/profile/presentation/pages/profile_page.dart
create mode 100644 lib/features/profile/presentation/pages/profile_page_builder.dart
create mode 100644 lib/features/profile/presentation/widgets/change_password_dialog.dart
create mode 100644 lib/features/profile/presentation/widgets/edit_profile_dialog.dart
create mode 100644 lib/features/profile/presentation/widgets/profile_dialog_shell.dart
create mode 100644 lib/features/profile/presentation/widgets/user_section_widget.dart
create mode 100644 test/features/profile/data/mappers/profile_failure_mapper_test.dart
create mode 100644 test/features/profile/data/repositories/profile_repository_impl_test.dart
create mode 100644 test/features/profile/presentation/cubits/change_password_cubit_test.dart
create mode 100644 test/features/profile/presentation/cubits/profile_user_cubit_test.dart
create mode 100644 test/features/profile/presentation/cubits/update_profile_cubit_test.dart
create mode 100644 test/features/profile/support/profile_dto_fixtures.dart
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ebde1f0..862e2dd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Authenticated tests catalog screen for the `/tests` root tab, reusing the existing tests catalog slice and adding local search plus multi-select category filtering UI before navigating to the debug screen.
- Workout execution feature for authenticated users, including execution DTOs/mappers, repository, Cubit, fullscreen route, local rest countdown, dialogs, and the warmup/training UI backed by workout start/progression/complete endpoints.
- Authenticated test attempt flow for `/tests/attempt/:testingId`, including auth API client methods, repository wiring, fullscreen attempt route, and the attempt UI mirrored from the Fitness Start flow.
+- Profile user section for the authenticated `/profile` tab, including `ProfileApiClient`, profile repository/failures, user section Cubits, edit-profile and change-password dialogs, avatar upload flow, and the first profile screen UI based on the provided layout.
### Changed
@@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Workouts overview and details app bars now reuse a dedicated `appBarTitle` text token instead of local per-page style overrides.
- Authenticated tests catalog cards now open the real test attempt flow instead of the debug screen.
- `TestingCatalogCard` now skips the extra spacing above category chips when a test has no categories.
+- The `/profile` root tab now renders the real user-section screen instead of the previous placeholder, reuses the authenticated session user as an initial seed, and keeps forgot-password routes reachable from the change-password dialog for authenticated users.
### Breaking
diff --git a/assets/icons/notification.svg b/assets/icons/notification.svg
new file mode 100644
index 00000000..435326d0
--- /dev/null
+++ b/assets/icons/notification.svg
@@ -0,0 +1,3 @@
+
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index e544677c..2da951e4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -5,6 +5,8 @@ PODS:
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
+ - image_picker_ios (0.0.1):
+ - Flutter
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
@@ -13,6 +15,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
+ - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
EXTERNAL SOURCES:
@@ -22,6 +25,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
+ image_picker_ios:
+ :path: ".symlinks/plugins/image_picker_ios/ios"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
@@ -29,6 +34,7 @@ SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
+ image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart
index 98ec585a..3e33b4e1 100644
--- a/lib/core/constants/app_assets.dart
+++ b/lib/core/constants/app_assets.dart
@@ -14,6 +14,7 @@ abstract final class AppAssets {
static const iconSearch = 'search';
static const iconFilter = 'filter';
static const iconClose = 'close';
+ static const iconNotification = 'notification';
static const iconBadFace = 'bad_face';
static const iconNormalFace = 'normal_face';
static const iconGoodFace = 'good_face';
diff --git a/lib/core/constants/app_strings.dart b/lib/core/constants/app_strings.dart
index 4dd24353..55a5723f 100644
--- a/lib/core/constants/app_strings.dart
+++ b/lib/core/constants/app_strings.dart
@@ -242,6 +242,27 @@ abstract final class AppStrings {
static const workoutExecutionWeightSecondary = 'Пропустить';
static const workoutExecutionWeightInvalid = 'Введите корректный вес в килограммах';
+ // Profile.
+ static String profileGreeting(String name) => 'Здравствуйте, $name';
+ static const profileEditButton = 'Редактировать профиль';
+ static const profileChangePasswordButton = 'Сменить пароль';
+ static const profileChangePasswordTitle = 'Смена пароля';
+ static const profileOldPasswordLabel = 'Старый пароль';
+ static const profileNewPasswordLabel = 'Новый пароль';
+ static const profilePasswordConfirmationLabel = 'Подтверждение пароля';
+ static const profileUploadFileLabel = 'Загрузить файл:';
+ static const profileUploadFormatPlaceholder = 'jpg формат';
+ static const profileEditEmailLabel = 'Введите email';
+ static const profileEditNameLabel = 'Введите имя';
+ static const profileSaveButton = 'Сохранить';
+ static const profileCancelButton = 'Отменить';
+ static const profileLoadFailed = 'Не удалось загрузить профиль';
+ static const profileValidationFailed = 'Проверьте введенные данные и попробуйте снова';
+ static const profileUpdateFailed = 'Не удалось обновить профиль. Попробуйте снова';
+ static const profileChangePasswordFailed = 'Не удалось сменить пароль. Попробуйте снова';
+ static const profileImagePickFailed = 'Не удалось выбрать изображение. Попробуйте снова';
+ static const profileUnknown = 'Не удалось выполнить действие. Попробуйте снова';
+
/// Builds the increase-adjustment message for a new absolute weight value.
static String workoutExecutionAdjustmentIncrease(String weight) =>
'На следующем подходе увеличьте вес до $weight $workoutExecutionWeightHint';
diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart
index 98fdff9c..4c34e725 100644
--- a/lib/core/di/di.dart
+++ b/lib/core/di/di.dart
@@ -17,6 +17,9 @@ import '../../features/fitness_start/data/remote/fitness_start_api_client.dart';
import '../../features/fitness_start/data/repositories/fitness_start_repository_impl.dart';
import '../../features/fitness_start/domain/repositories/fitness_start_repository.dart';
import '../../features/offline/presentation/cubit/network_cubit.dart';
+import '../../features/profile/data/remote/profile_api_client.dart';
+import '../../features/profile/data/repositories/profile_repository_impl.dart';
+import '../../features/profile/domain/repositories/profile_repository.dart';
import '../../features/tests/attempt/data/repositories/authenticated_test_attempt_repository_impl.dart';
import '../../features/tests/attempt/data/repositories/guest_test_attempt_repository_impl.dart';
import '../../features/tests/attempt/domain/repositories/test_attempt_repository.dart';
@@ -112,6 +115,13 @@ Future setupDI() async {
di(),
),
);
+ di.registerLazySingleton(() => ProfileApiClient(di()));
+ di.registerLazySingleton(
+ () => ProfileRepositoryImpl(
+ di(),
+ di(),
+ ),
+ );
// Fitness Start
di.registerLazySingleton(() => FitnessStartApiClient(di()));
diff --git a/lib/core/failures/feature/profile/profile_failure.dart b/lib/core/failures/feature/profile/profile_failure.dart
new file mode 100644
index 00000000..bbf8696f
--- /dev/null
+++ b/lib/core/failures/feature/profile/profile_failure.dart
@@ -0,0 +1,41 @@
+import '../../../constants/app_strings.dart';
+import '../../app_failure.dart';
+
+/// Profile application error.
+sealed class ProfileFailure extends AppFailure {
+ /// Creates an instance of [ProfileFailure].
+ const ProfileFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Profile validation failed because the provided input is invalid.
+final class ProfileValidationFailure extends ProfileFailure {
+ /// Creates an instance of [ProfileValidationFailure].
+ const ProfileValidationFailure({
+ String message = AppStrings.profileValidationFailed,
+ super.parentException,
+ super.stackTrace,
+ }) : super(message);
+}
+
+/// Profile request failed because of infrastructure or network conditions.
+final class ProfileRequestFailure extends ProfileFailure {
+ /// Creates an instance of [ProfileRequestFailure].
+ const ProfileRequestFailure(
+ super.message, {
+ super.parentException,
+ super.stackTrace,
+ });
+}
+
+/// Unknown profile failure.
+final class UnknownProfileFailure extends ProfileFailure {
+ /// Creates an instance of [UnknownProfileFailure].
+ const UnknownProfileFailure({
+ super.parentException,
+ super.stackTrace,
+ }) : super(AppStrings.profileUnknown);
+}
diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart
index 06fcd002..66f2b35f 100644
--- a/lib/core/network/api_paths.dart
+++ b/lib/core/network/api_paths.dart
@@ -41,6 +41,15 @@ abstract class ApiPaths {
/// The endpoint for the current user profile.
static const String me = '${apiPrefix}me';
+ /// The endpoint for the authenticated profile payload.
+ static const String profile = '${apiPrefix}profile';
+
+ /// The endpoint for changing the authenticated user password.
+ static const String profileChangePassword = '$profile/change-password';
+
+ /// The endpoint for uploading or deleting the authenticated user avatar.
+ static const String profileAvatar = '$profile/avatar';
+
/// The endpoint for all user-parameters references.
static const String userParameterReferences = '${apiPrefix}user-parameters/references';
diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart
index d7d58194..f9ec2d0c 100644
--- a/lib/core/router/router.dart
+++ b/lib/core/router/router.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
-import '../../core/constants/app_strings.dart';
import '../../features/auth/presentation/cubits/auth_session_cubit.dart';
import '../../features/auth/presentation/pages/forgot_password_page_builder.dart';
import '../../features/auth/presentation/pages/legal_document_page.dart';
@@ -21,6 +20,7 @@ import '../../features/fitness_start/presentation/pages/fitness_start_test_attem
import '../../features/fitness_start/presentation/pages/fitness_start_tests_page_builder.dart';
import '../../features/offline/presentation/cubit/network_cubit.dart';
import '../../features/offline/presentation/pages/offline_page.dart';
+import '../../features/profile/presentation/pages/profile_page_builder.dart';
import '../../features/root/presentation/pages/root_screen.dart';
import '../../features/splash/presentation/pages/splash_page.dart';
import '../../features/tests/attempt/presentation/pages/tests_attempt_page_builder.dart';
@@ -29,8 +29,6 @@ import '../../features/workouts/details/presentation/pages/workout_details_page_
import '../../features/workouts/execution/domain/entities/workout_execution_entry_mode.dart';
import '../../features/workouts/execution/presentation/pages/workout_execution_page_builder.dart';
import '../../features/workouts/overview/presentation/pages/workouts_overview_page_builder.dart';
-import '../../uikit/themes/colors/app_color_theme.dart';
-import '../../uikit/themes/text/app_text_theme.dart';
import '../di/di.dart';
import '../utils/analytics/app_analytics.dart';
import 'analytics_route_observer.dart';
@@ -60,6 +58,11 @@ bool _isGuestCompletedAllowedPath(String path) =>
path == AppRoutePaths.resetPasswordPath ||
path == AppRoutePaths.legalDocumentPath;
+bool _isAuthenticatedAllowedAuthPath(String path) =>
+ path == AppRoutePaths.forgotPasswordPath ||
+ path == AppRoutePaths.verifyResetCodePath ||
+ path == AppRoutePaths.resetPasswordPath;
+
/// Determines the redirect path based on the current [networkState], [authState] and [state].
String? _redirect(
NetworkState networkState,
@@ -142,6 +145,7 @@ String? _redirectByAuth(
state.matchedLocation == AppRoutePaths.debugPath) {
return null;
}
+ if (_isAuthenticatedAllowedAuthPath(state.matchedLocation)) return null;
if (isSplashScreen || isAuthScreen || isFitnessStartScreen) {
return AppRoutePaths.workoutsPath;
}
@@ -245,9 +249,7 @@ final router = GoRouter(
routes: [
GoRoute(
path: AppRoutePaths.profilePath,
- builder: (_, _) => const _RootPlaceholderScreen(
- screenName: AppStrings.profileTab,
- ),
+ builder: (_, _) => const ProfilePageBuilder(),
),
],
),
@@ -388,23 +390,3 @@ class CombinedRouterRefreshListenable extends ChangeNotifier {
super.dispose();
}
}
-
-final class _RootPlaceholderScreen extends StatelessWidget {
- final String screenName;
-
- const _RootPlaceholderScreen({required this.screenName});
-
- @override
- Widget build(BuildContext context) {
- final textTheme = AppTextTheme.of(context);
- final colorTheme = AppColorTheme.of(context);
- return Scaffold(
- body: Center(
- child: Text(
- screenName,
- style: textTheme.title.copyWith(color: colorTheme.onSurface),
- ),
- ),
- );
- }
-}
diff --git a/lib/features/auth/presentation/cubits/auth_session_cubit.dart b/lib/features/auth/presentation/cubits/auth_session_cubit.dart
index 8c4fe84b..4413a5a3 100644
--- a/lib/features/auth/presentation/cubits/auth_session_cubit.dart
+++ b/lib/features/auth/presentation/cubits/auth_session_cubit.dart
@@ -248,6 +248,17 @@ final class AuthSessionCubit extends Cubit {
emit(AuthSessionState.authenticated(user));
}
+ /// Updates the authenticated user payload without changing the auth mode.
+ void updateAuthenticatedUser(User user) {
+ final canUpdate = state.maybeWhen(
+ authenticated: (_) => true,
+ orElse: () => false,
+ );
+ if (!canUpdate || isClosed) return;
+
+ emit(AuthSessionState.authenticated(user));
+ }
+
/// Marks the current session as unauthenticated.
Future clearSession() async {
final isCleared = await _clearGuestDataSafely();
diff --git a/lib/features/profile/data/dto/change_password_request_dto.dart b/lib/features/profile/data/dto/change_password_request_dto.dart
new file mode 100644
index 00000000..3b0edc74
--- /dev/null
+++ b/lib/features/profile/data/dto/change_password_request_dto.dart
@@ -0,0 +1,29 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'change_password_request_dto.g.dart';
+
+/// DTO for changing the authenticated user password.
+@JsonSerializable(createFactory: false)
+class ChangePasswordRequestDto {
+ /// Current password.
+ @JsonKey(name: 'old_password')
+ final String oldPassword;
+
+ /// New password.
+ @JsonKey(name: 'new_password')
+ final String newPassword;
+
+ /// New password confirmation.
+ @JsonKey(name: 'new_password_confirmation')
+ final String newPasswordConfirmation;
+
+ /// Creates an instance of [ChangePasswordRequestDto].
+ ChangePasswordRequestDto({
+ required this.oldPassword,
+ required this.newPassword,
+ required this.newPasswordConfirmation,
+ });
+
+ /// Converts [ChangePasswordRequestDto] to JSON.
+ Map toJson() => _$ChangePasswordRequestDtoToJson(this);
+}
diff --git a/lib/features/profile/data/dto/profile_user_data_dto.dart b/lib/features/profile/data/dto/profile_user_data_dto.dart
new file mode 100644
index 00000000..973d099c
--- /dev/null
+++ b/lib/features/profile/data/dto/profile_user_data_dto.dart
@@ -0,0 +1,21 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import 'profile_user_dto.dart';
+
+part 'profile_user_data_dto.g.dart';
+
+/// DTO for the profile `data` payload.
+@JsonSerializable(createToJson: false)
+class ProfileUserDataDto {
+ /// Current authenticated user.
+ final ProfileUserDto user;
+
+ /// Creates an instance of [ProfileUserDataDto].
+ ProfileUserDataDto({
+ required this.user,
+ });
+
+ /// Creates a [ProfileUserDataDto] from JSON.
+ factory ProfileUserDataDto.fromJson(Map json) =>
+ _$ProfileUserDataDtoFromJson(json);
+}
diff --git a/lib/features/profile/data/dto/profile_user_dto.dart b/lib/features/profile/data/dto/profile_user_dto.dart
new file mode 100644
index 00000000..a371a7d9
--- /dev/null
+++ b/lib/features/profile/data/dto/profile_user_dto.dart
@@ -0,0 +1,41 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'profile_user_dto.g.dart';
+
+/// DTO for the authenticated profile user payload.
+@JsonSerializable(createToJson: false)
+class ProfileUserDto {
+ /// Unique identifier for the user.
+ final int id;
+
+ /// Name of the user.
+ final String name;
+
+ /// Email address of the user.
+ final String email;
+
+ /// Public avatar URL.
+ @JsonKey(name: 'avatar_url')
+ final String? avatarUrl;
+
+ /// Account creation timestamp.
+ @JsonKey(name: 'created_at')
+ final String createdAt;
+
+ /// Whether the email is verified.
+ @JsonKey(name: 'email_verified')
+ final bool emailVerified;
+
+ /// Creates an instance of [ProfileUserDto].
+ ProfileUserDto({
+ required this.id,
+ required this.name,
+ required this.email,
+ required this.avatarUrl,
+ required this.createdAt,
+ required this.emailVerified,
+ });
+
+ /// Creates a [ProfileUserDto] from JSON.
+ factory ProfileUserDto.fromJson(Map json) => _$ProfileUserDtoFromJson(json);
+}
diff --git a/lib/features/profile/data/dto/profile_user_response_dto.dart b/lib/features/profile/data/dto/profile_user_response_dto.dart
new file mode 100644
index 00000000..b551d166
--- /dev/null
+++ b/lib/features/profile/data/dto/profile_user_response_dto.dart
@@ -0,0 +1,21 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import 'profile_user_data_dto.dart';
+
+part 'profile_user_response_dto.g.dart';
+
+/// DTO for the authenticated profile response.
+@JsonSerializable(createToJson: false)
+class ProfileUserResponseDto {
+ /// Nested data payload.
+ final ProfileUserDataDto data;
+
+ /// Creates an instance of [ProfileUserResponseDto].
+ ProfileUserResponseDto({
+ required this.data,
+ });
+
+ /// Creates a [ProfileUserResponseDto] from JSON.
+ factory ProfileUserResponseDto.fromJson(Map json) =>
+ _$ProfileUserResponseDtoFromJson(json);
+}
diff --git a/lib/features/profile/data/dto/update_profile_request_dto.dart b/lib/features/profile/data/dto/update_profile_request_dto.dart
new file mode 100644
index 00000000..70cf791b
--- /dev/null
+++ b/lib/features/profile/data/dto/update_profile_request_dto.dart
@@ -0,0 +1,22 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'update_profile_request_dto.g.dart';
+
+/// DTO for updating the authenticated profile.
+@JsonSerializable(createFactory: false)
+class UpdateProfileRequestDto {
+ /// New profile name.
+ final String name;
+
+ /// New profile email.
+ final String email;
+
+ /// Creates an instance of [UpdateProfileRequestDto].
+ UpdateProfileRequestDto({
+ required this.name,
+ required this.email,
+ });
+
+ /// Converts [UpdateProfileRequestDto] to JSON.
+ Map toJson() => _$UpdateProfileRequestDtoToJson(this);
+}
diff --git a/lib/features/profile/data/mappers/profile_failure_mapper.dart b/lib/features/profile/data/mappers/profile_failure_mapper.dart
new file mode 100644
index 00000000..df2ba632
--- /dev/null
+++ b/lib/features/profile/data/mappers/profile_failure_mapper.dart
@@ -0,0 +1,52 @@
+import '../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../core/failures/helpers/validation_message_builder.dart';
+import '../../../../core/failures/network/network_failure.dart';
+
+/// Extension to map [NetworkFailure] into [ProfileFailure].
+extension ProfileFailureMapper on NetworkFailure {
+ /// Maps a [NetworkFailure] into a profile-specific failure.
+ ProfileFailure toProfileFailure() {
+ final fieldErrors = switch (this) {
+ ValidationFailure(:final errors) => errors,
+ _ => const >{},
+ };
+ final validationMessage = buildValidationMessage(
+ fieldErrors,
+ fallbackMessage: const ProfileValidationFailure().message,
+ );
+
+ switch (code) {
+ case 'validation_failed':
+ return ProfileValidationFailure(
+ message: validationMessage,
+ parentException: parentException,
+ stackTrace: stackTrace,
+ );
+ case 'token_expired':
+ case 'session_expired_inactivity':
+ case 'session_expired_absolute':
+ case 'unauthorized':
+ default:
+ return switch (this) {
+ NoNetworkFailure() ||
+ ConnectionTimeoutFailure() ||
+ BadRequestFailure() ||
+ UnauthorizedFailure() ||
+ ForbiddenFailure() ||
+ NotFoundFailure() ||
+ ConflictFailure() ||
+ RateLimitedFailure() ||
+ ServerErrorFailure() ||
+ UnknownNetworkFailure() => ProfileRequestFailure(
+ message,
+ parentException: parentException,
+ stackTrace: stackTrace,
+ ),
+ _ => UnknownProfileFailure(
+ parentException: parentException,
+ stackTrace: stackTrace,
+ ),
+ };
+ }
+ }
+}
diff --git a/lib/features/profile/data/mappers/profile_user_entity_mapper.dart b/lib/features/profile/data/mappers/profile_user_entity_mapper.dart
new file mode 100644
index 00000000..fd2d4d42
--- /dev/null
+++ b/lib/features/profile/data/mappers/profile_user_entity_mapper.dart
@@ -0,0 +1,13 @@
+import '../../../auth/domain/entities/user.dart';
+import '../dto/profile_user_dto.dart';
+
+/// Extension to map [ProfileUserDto] into the shared auth [User] entity.
+extension ProfileUserEntityMapper on ProfileUserDto {
+ /// Maps [ProfileUserDto] to [User].
+ User toEntity() => User(
+ id: id,
+ name: name,
+ email: email,
+ avatar: avatarUrl,
+ );
+}
diff --git a/lib/features/profile/data/remote/profile_api_client.dart b/lib/features/profile/data/remote/profile_api_client.dart
new file mode 100644
index 00000000..10fbb31a
--- /dev/null
+++ b/lib/features/profile/data/remote/profile_api_client.dart
@@ -0,0 +1,33 @@
+import 'package:dio/dio.dart';
+import 'package:retrofit/retrofit.dart';
+
+import '../../../../core/network/api_paths.dart';
+import '../dto/change_password_request_dto.dart';
+import '../dto/profile_user_response_dto.dart';
+import '../dto/update_profile_request_dto.dart';
+
+part 'profile_api_client.g.dart';
+
+/// Retrofit API client for authenticated profile operations.
+@RestApi()
+abstract class ProfileApiClient {
+ /// Creates an instance of [ProfileApiClient].
+ factory ProfileApiClient(Dio dio, {String? baseUrl}) = _ProfileApiClient;
+
+ /// Returns the authenticated profile payload.
+ @GET(ApiPaths.profile)
+ Future getProfile();
+
+ /// Updates the authenticated user profile fields.
+ @PUT(ApiPaths.profile)
+ Future updateProfile(@Body() UpdateProfileRequestDto request);
+
+ /// Changes the authenticated user password.
+ @POST(ApiPaths.profileChangePassword)
+ Future changePassword(@Body() ChangePasswordRequestDto request);
+
+ /// Uploads or replaces the authenticated user avatar.
+ @MultiPart()
+ @POST(ApiPaths.profileAvatar)
+ Future uploadAvatar(@Part(name: 'avatar') MultipartFile avatar);
+}
diff --git a/lib/features/profile/data/repositories/profile_repository_impl.dart b/lib/features/profile/data/repositories/profile_repository_impl.dart
new file mode 100644
index 00000000..a96fddf1
--- /dev/null
+++ b/lib/features/profile/data/repositories/profile_repository_impl.dart
@@ -0,0 +1,113 @@
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+
+import '../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../core/network/mappers/dio_exception_mapper.dart';
+import '../../../../core/result/result.dart';
+import '../../../../core/utils/logger/app_logger.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../domain/repositories/profile_repository.dart';
+import '../dto/change_password_request_dto.dart';
+import '../dto/update_profile_request_dto.dart';
+import '../mappers/profile_failure_mapper.dart';
+import '../mappers/profile_user_entity_mapper.dart';
+import '../remote/profile_api_client.dart';
+
+/// Implementation of [ProfileRepository].
+final class ProfileRepositoryImpl implements ProfileRepository {
+ final AppLogger _logger;
+ final ProfileApiClient _apiClient;
+
+ /// Creates an instance of [ProfileRepositoryImpl].
+ ProfileRepositoryImpl(this._logger, this._apiClient);
+
+ @override
+ Future> getUser() async {
+ try {
+ final response = await _apiClient.getProfile();
+ return Result.success(response.data.user.toEntity());
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toProfileFailure());
+ } catch (e, s) {
+ _logger.e('GetUser failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownProfileFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+
+ @override
+ Future> updateUser({
+ required User currentUser,
+ required String name,
+ required String email,
+ String? avatarPath,
+ }) async {
+ final normalizedName = name.trim();
+ final normalizedEmail = email.trim();
+ final normalizedAvatarPath = avatarPath?.trim();
+ final hasProfileChanges =
+ normalizedName != currentUser.name || normalizedEmail != currentUser.email;
+ final hasAvatarChange = normalizedAvatarPath != null && normalizedAvatarPath.isNotEmpty;
+
+ if (!hasProfileChanges && !hasAvatarChange) {
+ return Result.success(currentUser);
+ }
+
+ try {
+ if (hasAvatarChange) {
+ final multipartFile = await MultipartFile.fromFile(
+ normalizedAvatarPath,
+ filename: normalizedAvatarPath.split(Platform.pathSeparator).last,
+ );
+ await _apiClient.uploadAvatar(multipartFile);
+ }
+
+ if (hasProfileChanges) {
+ final request = UpdateProfileRequestDto(
+ name: normalizedName,
+ email: normalizedEmail,
+ );
+ await _apiClient.updateProfile(request);
+ }
+
+ final refreshedResponse = await _apiClient.getProfile();
+ return Result.success(refreshedResponse.data.user.toEntity());
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toProfileFailure());
+ } catch (e, s) {
+ _logger.e('UpdateUser failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownProfileFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+
+ @override
+ Future> changePassword({
+ required String oldPassword,
+ required String newPassword,
+ required String newPasswordConfirmation,
+ }) async {
+ try {
+ final request = ChangePasswordRequestDto(
+ oldPassword: oldPassword,
+ newPassword: newPassword,
+ newPasswordConfirmation: newPasswordConfirmation,
+ );
+ await _apiClient.changePassword(request);
+ return const Result.success(null);
+ } on DioException catch (e) {
+ final networkFailure = e.toNetworkFailure();
+ return Result.failure(networkFailure.toProfileFailure());
+ } catch (e, s) {
+ _logger.e('ChangePassword failed with unexpected error', e, s);
+ return Result.failure(
+ UnknownProfileFailure(parentException: e, stackTrace: s),
+ );
+ }
+ }
+}
diff --git a/lib/features/profile/domain/repositories/profile_repository.dart b/lib/features/profile/domain/repositories/profile_repository.dart
new file mode 100644
index 00000000..b8f1f18c
--- /dev/null
+++ b/lib/features/profile/domain/repositories/profile_repository.dart
@@ -0,0 +1,24 @@
+import '../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../core/result/result.dart';
+import '../../../auth/domain/entities/user.dart';
+
+/// Repository interface for authenticated profile operations.
+abstract interface class ProfileRepository {
+ /// Returns the current authenticated user from the profile payload.
+ Future> getUser();
+
+ /// Updates the current user profile and returns the canonical refreshed user payload.
+ Future> updateUser({
+ required User currentUser,
+ required String name,
+ required String email,
+ String? avatarPath,
+ });
+
+ /// Changes the current authenticated user password.
+ Future> changePassword({
+ required String oldPassword,
+ required String newPassword,
+ required String newPasswordConfirmation,
+ });
+}
diff --git a/lib/features/profile/presentation/cubits/change_password_cubit.dart b/lib/features/profile/presentation/cubits/change_password_cubit.dart
new file mode 100644
index 00000000..5d269d68
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/change_password_cubit.dart
@@ -0,0 +1,46 @@
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+import '../../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../../core/result/result.dart';
+import '../../domain/repositories/profile_repository.dart';
+
+part 'change_password_cubit.freezed.dart';
+part 'change_password_state.dart';
+
+/// Cubit that manages the change-password flow.
+final class ChangePasswordCubit extends Cubit {
+ final ProfileRepository _repository;
+
+ /// Creates an instance of [ChangePasswordCubit].
+ ChangePasswordCubit(this._repository) : super(const ChangePasswordState.initial());
+
+ /// Attempts to change the current authenticated user password.
+ Future changePassword({
+ required String oldPassword,
+ required String newPassword,
+ required String newPasswordConfirmation,
+ }) async {
+ final isInProgress = state.maybeWhen(
+ inProgress: () => true,
+ orElse: () => false,
+ );
+ if (isInProgress) return;
+
+ emit(const ChangePasswordState.inProgress());
+
+ final result = await _repository.changePassword(
+ oldPassword: oldPassword,
+ newPassword: newPassword,
+ newPasswordConfirmation: newPasswordConfirmation,
+ );
+ if (isClosed) return;
+
+ switch (result) {
+ case Success():
+ emit(const ChangePasswordState.succeed());
+ case Failure(:final error):
+ emit(ChangePasswordState.failed(error));
+ }
+ }
+}
diff --git a/lib/features/profile/presentation/cubits/change_password_state.dart b/lib/features/profile/presentation/cubits/change_password_state.dart
new file mode 100644
index 00000000..6c3be339
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/change_password_state.dart
@@ -0,0 +1,17 @@
+part of 'change_password_cubit.dart';
+
+/// States for [ChangePasswordCubit].
+@freezed
+class ChangePasswordState with _$ChangePasswordState {
+ /// Initial idle state.
+ const factory ChangePasswordState.initial() = _Initial;
+
+ /// Submit is in progress.
+ const factory ChangePasswordState.inProgress() = _InProgress;
+
+ /// Password change succeeded.
+ const factory ChangePasswordState.succeed() = _Succeed;
+
+ /// Password change failed.
+ const factory ChangePasswordState.failed(ProfileFailure failure) = _Failed;
+}
diff --git a/lib/features/profile/presentation/cubits/profile_user_cubit.dart b/lib/features/profile/presentation/cubits/profile_user_cubit.dart
new file mode 100644
index 00000000..745c52fe
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/profile_user_cubit.dart
@@ -0,0 +1,66 @@
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+import '../../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../../core/result/result.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../domain/repositories/profile_repository.dart';
+
+part 'profile_user_cubit.freezed.dart';
+part 'profile_user_state.dart';
+
+/// Cubit that manages the authenticated profile user section payload.
+final class ProfileUserCubit extends Cubit {
+ final ProfileRepository _repository;
+
+ /// Creates an instance of [ProfileUserCubit].
+ ProfileUserCubit(
+ this._repository, {
+ User? seedUser,
+ }) : super(ProfileUserState(user: seedUser));
+
+ /// Refreshes the canonical profile user payload.
+ Future refresh() async {
+ if (state.isLoading) return;
+
+ emit(
+ state.copyWith(
+ isLoading: true,
+ failure: null,
+ ),
+ );
+
+ final result = await _repository.getUser();
+ if (isClosed) return;
+
+ switch (result) {
+ case Success(data: final user):
+ emit(
+ state.copyWith(
+ isLoading: false,
+ user: user,
+ failure: null,
+ ),
+ );
+ case Failure(:final error):
+ emit(
+ state.copyWith(
+ isLoading: false,
+ failure: error,
+ ),
+ );
+ }
+ }
+
+ /// Replaces the current user with a freshly updated payload.
+ void replaceUser(User user) {
+ if (isClosed) return;
+
+ emit(
+ state.copyWith(
+ user: user,
+ failure: null,
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/cubits/profile_user_state.dart b/lib/features/profile/presentation/cubits/profile_user_state.dart
new file mode 100644
index 00000000..a070f8b8
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/profile_user_state.dart
@@ -0,0 +1,12 @@
+part of 'profile_user_cubit.dart';
+
+/// State for [ProfileUserCubit].
+@freezed
+abstract class ProfileUserState with _$ProfileUserState {
+ /// Creates an instance of [ProfileUserState].
+ const factory ProfileUserState({
+ @Default(false) bool isLoading,
+ User? user,
+ ProfileFailure? failure,
+ }) = _ProfileUserState;
+}
diff --git a/lib/features/profile/presentation/cubits/update_profile_cubit.dart b/lib/features/profile/presentation/cubits/update_profile_cubit.dart
new file mode 100644
index 00000000..25d70f42
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/update_profile_cubit.dart
@@ -0,0 +1,49 @@
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+import '../../../../../core/failures/feature/profile/profile_failure.dart';
+import '../../../../../core/result/result.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../domain/repositories/profile_repository.dart';
+
+part 'update_profile_cubit.freezed.dart';
+part 'update_profile_state.dart';
+
+/// Cubit that manages edit-profile submit flow.
+final class UpdateProfileCubit extends Cubit {
+ final ProfileRepository _repository;
+
+ /// Creates an instance of [UpdateProfileCubit].
+ UpdateProfileCubit(this._repository) : super(const UpdateProfileState.initial());
+
+ /// Updates the authenticated profile.
+ Future updateProfile({
+ required User currentUser,
+ required String name,
+ required String email,
+ String? avatarPath,
+ }) async {
+ final isInProgress = state.maybeWhen(
+ inProgress: () => true,
+ orElse: () => false,
+ );
+ if (isInProgress) return;
+
+ emit(const UpdateProfileState.inProgress());
+
+ final result = await _repository.updateUser(
+ currentUser: currentUser,
+ name: name,
+ email: email,
+ avatarPath: avatarPath,
+ );
+ if (isClosed) return;
+
+ switch (result) {
+ case Success(data: final user):
+ emit(UpdateProfileState.succeed(user));
+ case Failure(:final error):
+ emit(UpdateProfileState.failed(error));
+ }
+ }
+}
diff --git a/lib/features/profile/presentation/cubits/update_profile_state.dart b/lib/features/profile/presentation/cubits/update_profile_state.dart
new file mode 100644
index 00000000..908c3b8a
--- /dev/null
+++ b/lib/features/profile/presentation/cubits/update_profile_state.dart
@@ -0,0 +1,17 @@
+part of 'update_profile_cubit.dart';
+
+/// States for [UpdateProfileCubit].
+@freezed
+class UpdateProfileState with _$UpdateProfileState {
+ /// Initial idle state.
+ const factory UpdateProfileState.initial() = _Initial;
+
+ /// Submit is in progress.
+ const factory UpdateProfileState.inProgress() = _InProgress;
+
+ /// Submit succeeded with a refreshed canonical user payload.
+ const factory UpdateProfileState.succeed(User user) = _Succeed;
+
+ /// Submit failed with a profile error.
+ const factory UpdateProfileState.failed(ProfileFailure failure) = _Failed;
+}
diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart
new file mode 100644
index 00000000..e767018c
--- /dev/null
+++ b/lib/features/profile/presentation/pages/profile_page.dart
@@ -0,0 +1,144 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+
+import '../../../../../core/constants/app_assets.dart';
+import '../../../../../core/constants/app_strings.dart';
+import '../../../../../core/router/router_paths.dart';
+import '../../../../../uikit/buttons/main_button.dart';
+import '../../../../../uikit/images/svg_picture_widget.dart';
+import '../../../../../uikit/themes/colors/app_color_theme.dart';
+import '../../../../../uikit/themes/text/app_text_theme.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../../auth/presentation/cubits/auth_session_cubit.dart';
+import '../cubits/profile_user_cubit.dart';
+import '../widgets/change_password_dialog.dart';
+import '../widgets/edit_profile_dialog.dart';
+import '../widgets/user_section_widget.dart';
+
+/// Authenticated profile page with the user section only.
+class ProfilePage extends StatelessWidget {
+ /// Creates an instance of [ProfilePage].
+ const ProfilePage({super.key});
+
+ Future _openEditProfileDialog(BuildContext context, User user) async {
+ final updatedUser = await showEditProfileDialog(context, user: user);
+ if (!context.mounted || updatedUser == null) return;
+
+ context.read().replaceUser(updatedUser);
+ context.read().updateAuthenticatedUser(updatedUser);
+ }
+
+ Future _openChangePasswordDialog(BuildContext context) async {
+ final result = await showChangePasswordDialog(context);
+ if (!context.mounted || result != ChangePasswordDialogResult.forgotPassword) return;
+
+ unawaited(context.push(AppRoutePaths.forgotPasswordPath));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final textTheme = AppTextTheme.of(context);
+ final colorTheme = AppColorTheme.of(context);
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(
+ AppStrings.profileTab,
+ style: textTheme.appBarTitle,
+ ),
+ actions: [
+ const Padding(
+ padding: EdgeInsets.only(right: 24),
+ child: Center(
+ child: ExcludeSemantics(
+ child: SvgPictureWidget.icon(AppAssets.iconNotification),
+ ),
+ ),
+ ),
+ ],
+ ),
+ body: BlocBuilder(
+ builder: (context, state) {
+ final user = state.user;
+ if (user == null) {
+ return _ProfileUserFallbackState(
+ isLoading: state.isLoading,
+ onRetryPressed: () => context.read().refresh(),
+ );
+ }
+ return SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 28, 24, 132),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ AppStrings.profileGreeting(user.name),
+ style: textTheme.bodyMedium.copyWith(
+ fontSize: 18,
+ height: 27 / 18,
+ fontWeight: FontWeight.w500,
+ color: colorTheme.onSurface,
+ ),
+ ),
+ const SizedBox(height: 24),
+ UserSectionWidget(
+ user: user,
+ onEditPressed: () => _openEditProfileDialog(context, user),
+ onChangePasswordPressed: () => _openChangePasswordDialog(context),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+final class _ProfileUserFallbackState extends StatelessWidget {
+ final bool isLoading;
+ final VoidCallback onRetryPressed;
+
+ const _ProfileUserFallbackState({
+ required this.isLoading,
+ required this.onRetryPressed,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final textTheme = AppTextTheme.of(context);
+ final colorTheme = AppColorTheme.of(context);
+
+ if (isLoading) {
+ return const Center(
+ child: SizedBox.square(
+ dimension: 24,
+ child: CircularProgressIndicator.adaptive(strokeWidth: 2),
+ ),
+ );
+ }
+
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ AppStrings.profileLoadFailed,
+ textAlign: TextAlign.center,
+ style: textTheme.bodyMedium.copyWith(color: colorTheme.onSurface),
+ ),
+ const SizedBox(height: 16),
+ MainButton(
+ onPressed: onRetryPressed,
+ child: const Text(AppStrings.retryButton),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/pages/profile_page_builder.dart b/lib/features/profile/presentation/pages/profile_page_builder.dart
new file mode 100644
index 00000000..0d6b399a
--- /dev/null
+++ b/lib/features/profile/presentation/pages/profile_page_builder.dart
@@ -0,0 +1,32 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import '../../../../../core/di/di.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../../auth/presentation/cubits/auth_session_cubit.dart';
+import '../../domain/repositories/profile_repository.dart';
+import '../cubits/profile_user_cubit.dart';
+import 'profile_page.dart';
+
+/// Builder for the authenticated profile page.
+class ProfilePageBuilder extends StatelessWidget {
+ /// Creates an instance of [ProfilePageBuilder].
+ const ProfilePageBuilder({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final initialUser = context.select(
+ (cubit) => cubit.state.maybeWhen(
+ authenticated: (user) => user,
+ orElse: () => null,
+ ),
+ );
+ return BlocProvider(
+ create: (_) => ProfileUserCubit(
+ di(),
+ seedUser: initialUser,
+ )..refresh(),
+ child: const ProfilePage(),
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/widgets/change_password_dialog.dart b/lib/features/profile/presentation/widgets/change_password_dialog.dart
new file mode 100644
index 00000000..e3535172
--- /dev/null
+++ b/lib/features/profile/presentation/widgets/change_password_dialog.dart
@@ -0,0 +1,177 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+import '../../../../../core/constants/app_strings.dart';
+import '../../../../../core/di/di.dart';
+import '../../../../../uikit/buttons/app_text_action.dart';
+import '../../../../../uikit/buttons/button_state.dart';
+import '../../../../../uikit/buttons/main_button.dart';
+import '../../../../../uikit/buttons/secondary_button.dart';
+import '../../../../../uikit/dialogs/app_feedback_dialog.dart';
+import '../../../../../uikit/themes/text/app_text_theme.dart';
+import '../../../auth/presentation/validators/auth_validators.dart';
+import '../../../auth/presentation/widgets/auth_password_field.dart';
+import '../../domain/repositories/profile_repository.dart';
+import '../cubits/change_password_cubit.dart';
+import 'profile_dialog_shell.dart';
+
+/// Result returned by the change-password dialog.
+enum ChangePasswordDialogResult {
+ /// The password was changed successfully.
+ changed,
+
+ /// The user wants to enter the forgot-password flow instead.
+ forgotPassword,
+}
+
+/// Opens the change-password dialog.
+Future showChangePasswordDialog(BuildContext context) {
+ return showProfileDialog(
+ context,
+ insetPadding: const EdgeInsets.symmetric(horizontal: 19.5),
+ child: BlocProvider(
+ create: (_) => ChangePasswordCubit(di()),
+ child: const ChangePasswordDialog(),
+ ),
+ );
+}
+
+/// Dialog for changing the authenticated user password.
+class ChangePasswordDialog extends StatefulWidget {
+ /// Creates an instance of [ChangePasswordDialog].
+ const ChangePasswordDialog({super.key});
+
+ @override
+ State createState() => _ChangePasswordDialogState();
+}
+
+class _ChangePasswordDialogState extends State {
+ final _formKey = GlobalKey();
+ final _oldPasswordController = TextEditingController();
+ final _newPasswordController = TextEditingController();
+ final _newPasswordConfirmationController = TextEditingController();
+
+ @override
+ void dispose() {
+ _oldPasswordController.dispose();
+ _newPasswordController.dispose();
+ _newPasswordConfirmationController.dispose();
+ super.dispose();
+ }
+
+ String? _confirmationValidator(String? value) {
+ if (value == null || value.isEmpty) {
+ return AppStrings.resetPasswordPasswordConfirmationRequired;
+ }
+ if (value != _newPasswordController.text) {
+ return AppStrings.resetPasswordPasswordMismatch;
+ }
+ return null;
+ }
+
+ void _submit() {
+ final form = _formKey.currentState;
+ if (form == null || !form.validate()) return;
+
+ context.read().changePassword(
+ oldPassword: _oldPasswordController.text,
+ newPassword: _newPasswordController.text,
+ newPasswordConfirmation: _newPasswordConfirmationController.text,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final textTheme = AppTextTheme.of(context);
+ return BlocConsumer(
+ listener: (context, state) {
+ state.whenOrNull(
+ succeed: () => Navigator.of(context).pop(ChangePasswordDialogResult.changed),
+ failed: (failure) {
+ if (failure.message.isEmpty) return;
+ showAppFeedbackDialog(
+ context,
+ title: AppStrings.feedbackErrorTitle,
+ message: failure.message,
+ );
+ },
+ );
+ },
+ builder: (context, state) {
+ final isInProgress = state.maybeWhen(
+ inProgress: () => true,
+ orElse: () => false,
+ );
+ return Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 10),
+ Text(
+ AppStrings.profileChangePasswordTitle,
+ textAlign: TextAlign.center,
+ style: textTheme.title.copyWith(
+ fontSize: 18,
+ height: 27 / 18,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 40),
+ AuthPasswordField(
+ controller: _oldPasswordController,
+ enabled: !isInProgress,
+ labelText: AppStrings.profileOldPasswordLabel,
+ textInputAction: TextInputAction.next,
+ validator: AuthValidators.password,
+ ),
+ const SizedBox(height: 8),
+ Align(
+ alignment: Alignment.centerLeft,
+ child: AppTextAction(
+ text: AppStrings.signInForgotPasswordButton,
+ onPressed: isInProgress
+ ? null
+ : () => Navigator.of(
+ context,
+ ).pop(ChangePasswordDialogResult.forgotPassword),
+ style: textTheme.bodyMedium,
+ ),
+ ),
+ const SizedBox(height: 12),
+ AuthPasswordField(
+ controller: _newPasswordController,
+ enabled: !isInProgress,
+ labelText: AppStrings.profileNewPasswordLabel,
+ textInputAction: TextInputAction.next,
+ validator: AuthValidators.password,
+ ),
+ const SizedBox(height: 12),
+ AuthPasswordField(
+ controller: _newPasswordConfirmationController,
+ enabled: !isInProgress,
+ labelText: AppStrings.profilePasswordConfirmationLabel,
+ textInputAction: TextInputAction.done,
+ onFieldSubmitted: (_) => _submit(),
+ validator: _confirmationValidator,
+ ),
+ const SizedBox(height: 36),
+ MainButton(
+ state: isInProgress ? ButtonState.loading : ButtonState.enabled,
+ onPressed: _submit,
+ child: const Text(AppStrings.profileChangePasswordButton),
+ ),
+ const SizedBox(height: 12),
+ SecondaryButton(
+ state: isInProgress ? ButtonState.disabled : ButtonState.enabled,
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text(AppStrings.profileCancelButton),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/widgets/edit_profile_dialog.dart b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart
new file mode 100644
index 00000000..40ae96a1
--- /dev/null
+++ b/lib/features/profile/presentation/widgets/edit_profile_dialog.dart
@@ -0,0 +1,291 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+import 'package:image_picker/image_picker.dart';
+
+import '../../../../../core/constants/app_strings.dart';
+import '../../../../../core/di/di.dart';
+import '../../../../../uikit/buttons/button_state.dart';
+import '../../../../../uikit/buttons/main_button.dart';
+import '../../../../../uikit/buttons/secondary_button.dart';
+import '../../../../../uikit/dialogs/app_feedback_dialog.dart';
+import '../../../../../uikit/images/network_image_widget.dart';
+import '../../../../../uikit/themes/colors/app_color_theme.dart';
+import '../../../../../uikit/themes/text/app_text_theme.dart';
+import '../../../auth/domain/entities/user.dart';
+import '../../../auth/presentation/validators/auth_validators.dart';
+import '../../../auth/presentation/widgets/auth_text_field.dart';
+import '../../domain/repositories/profile_repository.dart';
+import '../cubits/update_profile_cubit.dart';
+import 'profile_dialog_shell.dart';
+
+/// Opens the edit-profile dialog and returns the refreshed user on success.
+Future showEditProfileDialog(
+ BuildContext context, {
+ required User user,
+}) {
+ return showProfileDialog(
+ context,
+ insetPadding: const EdgeInsets.symmetric(horizontal: 11.5),
+ child: BlocProvider(
+ create: (_) => UpdateProfileCubit(di()),
+ child: EditProfileDialog(user: user),
+ ),
+ );
+}
+
+/// Dialog for editing the authenticated profile.
+class EditProfileDialog extends StatefulWidget {
+ /// Current authenticated user.
+ final User user;
+
+ /// Creates an instance of [EditProfileDialog].
+ const EditProfileDialog({
+ required this.user,
+ super.key,
+ });
+
+ @override
+ State createState() => _EditProfileDialogState();
+}
+
+class _EditProfileDialogState extends State {
+ final _formKey = GlobalKey();
+ final _emailController = TextEditingController();
+ final _nameController = TextEditingController();
+ final _picker = ImagePicker();
+
+ String? _selectedAvatarPath;
+
+ @override
+ void initState() {
+ super.initState();
+ _emailController.text = widget.user.email;
+ _nameController.text = widget.user.name;
+ }
+
+ @override
+ void dispose() {
+ _emailController.dispose();
+ _nameController.dispose();
+ super.dispose();
+ }
+
+ Future _pickAvatar() async {
+ try {
+ final file = await _picker.pickImage(
+ source: ImageSource.gallery,
+ );
+ if (file == null || !mounted) return;
+
+ setState(() {
+ _selectedAvatarPath = file.path;
+ });
+ } catch (_) {
+ if (!mounted) return;
+ await showAppFeedbackDialog(
+ context,
+ title: AppStrings.feedbackErrorTitle,
+ message: AppStrings.profileImagePickFailed,
+ );
+ }
+ }
+
+ void _submit() {
+ final form = _formKey.currentState;
+ if (form == null || !form.validate()) return;
+
+ context.read().updateProfile(
+ currentUser: widget.user,
+ name: _nameController.text,
+ email: _emailController.text,
+ avatarPath: _selectedAvatarPath,
+ );
+ }
+
+ String get _formatLabel {
+ final avatarPath = _selectedAvatarPath;
+ if (avatarPath == null || avatarPath.isEmpty) {
+ return AppStrings.profileUploadFormatPlaceholder;
+ }
+
+ final fileName = avatarPath.split(Platform.pathSeparator).last;
+ final dotIndex = fileName.lastIndexOf('.');
+ if (dotIndex <= 0 || dotIndex == fileName.length - 1) {
+ return AppStrings.profileUploadFormatPlaceholder;
+ }
+
+ final extension = fileName.substring(dotIndex + 1).toLowerCase();
+ return '$extension формат';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorTheme = AppColorTheme.of(context);
+ final textTheme = AppTextTheme.of(context);
+ return BlocConsumer(
+ listener: (context, state) {
+ state.whenOrNull(
+ succeed: (user) => Navigator.of(context).pop(user),
+ failed: (failure) {
+ if (failure.message.isEmpty) return;
+ showAppFeedbackDialog(
+ context,
+ title: AppStrings.feedbackErrorTitle,
+ message: failure.message,
+ );
+ },
+ );
+ },
+ builder: (context, state) {
+ final isInProgress = state.maybeWhen(
+ inProgress: () => true,
+ orElse: () => false,
+ );
+ return Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(9),
+ child: _AvatarPreview(
+ imageUrl: widget.user.avatar,
+ localAvatarPath: _selectedAvatarPath,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ AppStrings.profileUploadFileLabel,
+ style: textTheme.body.copyWith(
+ fontWeight: FontWeight.w500,
+ color: colorTheme.onSurface,
+ ),
+ ),
+ ),
+ _AvatarPickButton(
+ onPressed: isInProgress ? null : _pickAvatar,
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: colorTheme.outline),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 6),
+ child: Text(
+ _formatLabel,
+ textAlign: TextAlign.center,
+ style: textTheme.button.copyWith(
+ color: colorTheme.hint,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 28),
+ AuthTextField(
+ controller: _emailController,
+ enabled: !isInProgress,
+ labelText: AppStrings.profileEditEmailLabel,
+ keyboardType: TextInputType.emailAddress,
+ textInputAction: TextInputAction.next,
+ validator: AuthValidators.email,
+ ),
+ const SizedBox(height: 12),
+ AuthTextField(
+ controller: _nameController,
+ enabled: !isInProgress,
+ labelText: AppStrings.profileEditNameLabel,
+ keyboardType: TextInputType.name,
+ textInputAction: TextInputAction.done,
+ onFieldSubmitted: (_) => _submit(),
+ validator: AuthValidators.name,
+ ),
+ const SizedBox(height: 36),
+ MainButton(
+ state: isInProgress ? ButtonState.loading : ButtonState.enabled,
+ onPressed: _submit,
+ child: const Text(AppStrings.profileSaveButton),
+ ),
+ const SizedBox(height: 8),
+ SecondaryButton(
+ state: isInProgress ? ButtonState.disabled : ButtonState.enabled,
+ onPressed: () => context.pop(),
+ child: const Text(AppStrings.profileCancelButton),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+final class _AvatarPreview extends StatelessWidget {
+ final String? imageUrl;
+ final String? localAvatarPath;
+
+ const _AvatarPreview({
+ required this.imageUrl,
+ required this.localAvatarPath,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final localAvatarPath = this.localAvatarPath;
+ if (localAvatarPath != null && localAvatarPath.isNotEmpty) {
+ return Image.file(
+ File(localAvatarPath),
+ height: 320,
+ width: double.infinity,
+ fit: BoxFit.cover,
+ );
+ }
+ return NetworkImageWidget(
+ imageUrl: imageUrl ?? '',
+ height: 296,
+ );
+ }
+}
+
+final class _AvatarPickButton extends StatelessWidget {
+ final VoidCallback? onPressed;
+
+ const _AvatarPickButton({required this.onPressed});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorTheme = AppColorTheme.of(context);
+ final textTheme = AppTextTheme.of(context);
+ return InkWell(
+ onTap: onPressed,
+ borderRadius: BorderRadius.circular(12),
+ child: Material(
+ color: Colors.transparent,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+ decoration: BoxDecoration(
+ color: onPressed == null ? colorTheme.disabled : colorTheme.primary,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Center(
+ child: Text(
+ '+',
+ textAlign: TextAlign.center,
+ style: textTheme.button.copyWith(color: colorTheme.onPrimary),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/widgets/profile_dialog_shell.dart b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart
new file mode 100644
index 00000000..514ec8e5
--- /dev/null
+++ b/lib/features/profile/presentation/widgets/profile_dialog_shell.dart
@@ -0,0 +1,58 @@
+import 'dart:ui';
+
+import 'package:flutter/material.dart';
+
+import '../../../../../uikit/themes/colors/app_color_theme.dart';
+
+/// Shows a profile-specific dialog with shared backdrop styling.
+Future showProfileDialog(
+ BuildContext context, {
+ required Widget child,
+ required EdgeInsets insetPadding,
+}) {
+ return showDialog(
+ context: context,
+ barrierDismissible: false,
+ barrierColor: AppColorTheme.of(context).onSurface.withValues(alpha: 0.16),
+ builder: (_) => PopScope(
+ canPop: false,
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
+ child: ProfileDialogShell(
+ insetPadding: insetPadding,
+ child: child,
+ ),
+ ),
+ ),
+ );
+}
+
+/// Shared rounded shell for profile dialogs.
+class ProfileDialogShell extends StatelessWidget {
+ /// Dialog outer insets.
+ final EdgeInsets insetPadding;
+
+ /// Dialog content.
+ final Widget child;
+
+ /// Creates an instance of [ProfileDialogShell].
+ const ProfileDialogShell({
+ required this.insetPadding,
+ required this.child,
+ super.key,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorTheme = AppColorTheme.of(context);
+ return Dialog(
+ insetPadding: insetPadding,
+ backgroundColor: colorTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/lib/features/profile/presentation/widgets/user_section_widget.dart b/lib/features/profile/presentation/widgets/user_section_widget.dart
new file mode 100644
index 00000000..cf40f40e
--- /dev/null
+++ b/lib/features/profile/presentation/widgets/user_section_widget.dart
@@ -0,0 +1,87 @@
+import 'package:flutter/material.dart';
+
+import '../../../../../core/constants/app_strings.dart';
+import '../../../../../uikit/buttons/main_button.dart';
+import '../../../../../uikit/buttons/secondary_button.dart';
+import '../../../../../uikit/cards/app_card.dart';
+import '../../../../../uikit/images/network_image_widget.dart';
+import '../../../../../uikit/themes/colors/app_color_theme.dart';
+import '../../../../../uikit/themes/text/app_text_theme.dart';
+import '../../../auth/domain/entities/user.dart';
+
+/// The first profile section with the basic authenticated user data.
+class UserSectionWidget extends StatelessWidget {
+ /// Current authenticated user.
+ final User user;
+
+ /// Callback for the edit-profile action.
+ final VoidCallback onEditPressed;
+
+ /// Callback for the change-password action.
+ final VoidCallback onChangePasswordPressed;
+
+ /// Creates an instance of [UserSectionWidget].
+ const UserSectionWidget({
+ required this.user,
+ required this.onEditPressed,
+ required this.onChangePasswordPressed,
+ super.key,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final textTheme = AppTextTheme.of(context);
+ final colorTheme = AppColorTheme.of(context);
+ return AppCard(
+ contentPadding: const EdgeInsets.all(24),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const SizedBox(height: 14),
+ Center(
+ child: ClipOval(
+ child: SizedBox.square(
+ dimension: 140,
+ child: NetworkImageWidget(
+ imageUrl: user.avatar ?? '',
+ height: 140,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Text(
+ user.name,
+ textAlign: TextAlign.center,
+ style: textTheme.bodyMedium.copyWith(
+ fontSize: 14,
+ height: 21 / 14,
+ fontWeight: FontWeight.w500,
+ color: colorTheme.onSurface,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ user.email,
+ textAlign: TextAlign.center,
+ style: textTheme.bodyMedium.copyWith(
+ fontSize: 14,
+ height: 21 / 14,
+ color: colorTheme.darkHint,
+ ),
+ ),
+ const SizedBox(height: 24),
+ MainButton(
+ onPressed: onEditPressed,
+ child: const Text(AppStrings.profileEditButton),
+ ),
+ const SizedBox(height: 12),
+ SecondaryButton(
+ onPressed: onChangePasswordPressed,
+ child: const Text(AppStrings.profileChangePasswordButton),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index e45918db..a6d26b23 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -241,6 +241,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.15.0"
+ cross_file:
+ dependency: transitive
+ description:
+ name: cross_file
+ sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -345,6 +353,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
+ file_selector_linux:
+ dependency: transitive
+ description:
+ name: file_selector_linux
+ sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.4"
+ file_selector_macos:
+ dependency: transitive
+ description:
+ name: file_selector_macos
+ sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.5"
+ file_selector_platform_interface:
+ dependency: transitive
+ description:
+ name: file_selector_platform_interface
+ sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.7.0"
+ file_selector_windows:
+ dependency: transitive
+ description:
+ name: file_selector_windows
+ sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@@ -382,6 +422,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ flutter_plugin_android_lifecycle:
+ dependency: transitive
+ description:
+ name: flutter_plugin_android_lifecycle
+ sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -560,6 +608,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image_picker:
+ dependency: "direct main"
+ description:
+ name: image_picker
+ sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ image_picker_android:
+ dependency: transitive
+ description:
+ name: image_picker_android
+ sha256: "9eae0cbd672549dacc18df855c2a23782afe4854ada5190b7d63b30ee0b0d3fd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.8.13+15"
+ image_picker_for_web:
+ dependency: transitive
+ description:
+ name: image_picker_for_web
+ sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
+ image_picker_ios:
+ dependency: transitive
+ description:
+ name: image_picker_ios
+ sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.8.13+6"
+ image_picker_linux:
+ dependency: transitive
+ description:
+ name: image_picker_linux
+ sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2"
+ image_picker_macos:
+ dependency: transitive
+ description:
+ name: image_picker_macos
+ sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2+1"
+ image_picker_platform_interface:
+ dependency: transitive
+ description:
+ name: image_picker_platform_interface
+ sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.11.1"
+ image_picker_windows:
+ dependency: transitive
+ description:
+ name: image_picker_windows
+ sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2"
io:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 100c53a7..06419637 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -24,6 +24,7 @@ dependencies:
get_it: ^9.2.0
go_router: ^17.1.0
hive_ce_flutter: ^2.3.4
+ image_picker: ^1.2.1
json_annotation: ^4.10.0
logger: ^2.6.2
path_provider: ^2.1.5
diff --git a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart
index 753b608f..23347e9e 100644
--- a/test/features/auth/presentation/cubits/auth_session_cubit_test.dart
+++ b/test/features/auth/presentation/cubits/auth_session_cubit_test.dart
@@ -204,6 +204,38 @@ void main() {
},
);
+ blocTest(
+ 'updateAuthenticatedUser emits authenticated(updatedUser) when session is authenticated',
+ build: () => authSessionCubit,
+ seed: () => const AuthSessionState.authenticated(user),
+ act: (cubit) => cubit.updateAuthenticatedUser(
+ const User(
+ id: 1,
+ name: 'updated_name',
+ email: 'updated@mail.com',
+ avatar: 'avatar.jpg',
+ ),
+ ),
+ expect: () => const [
+ AuthSessionState.authenticated(
+ User(
+ id: 1,
+ name: 'updated_name',
+ email: 'updated@mail.com',
+ avatar: 'avatar.jpg',
+ ),
+ ),
+ ],
+ );
+
+ blocTest(
+ 'updateAuthenticatedUser is no-op when session is not authenticated',
+ build: () => authSessionCubit,
+ seed: () => const AuthSessionState.unauthenticated(),
+ act: (cubit) => cubit.updateAuthenticatedUser(user),
+ expect: () => const [],
+ );
+
blocTest(
'startGuestFitnessStart emits guest',
setUp: () => when(tokenStorage.getAccessToken()).thenAnswer((_) async => null),
diff --git a/test/features/profile/data/mappers/profile_failure_mapper_test.dart b/test/features/profile/data/mappers/profile_failure_mapper_test.dart
new file mode 100644
index 00000000..9bbdd09c
--- /dev/null
+++ b/test/features/profile/data/mappers/profile_failure_mapper_test.dart
@@ -0,0 +1,74 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:moveup_flutter/core/constants/app_strings.dart';
+import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart';
+import 'package:moveup_flutter/core/failures/network/network_failure.dart';
+import 'package:moveup_flutter/features/profile/data/mappers/profile_failure_mapper.dart';
+
+void main() {
+ group('ProfileFailureMapper.toProfileFailure', () {
+ test('maps validation_failed to ProfileValidationFailure with validation message', () {
+ const failure = ValidationFailure(
+ errors: {
+ 'email': ['error_message'],
+ },
+ );
+
+ final result = failure.toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, 'error_message');
+ });
+
+ test('falls back to generic profile validation message when field errors are empty', () {
+ final result = const ValidationFailure().toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, AppStrings.profileValidationFailed);
+ });
+
+ test('maps NoNetworkFailure to ProfileRequestFailure', () {
+ final result = const NoNetworkFailure().toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, const NoNetworkFailure().message);
+ });
+
+ test('maps ConnectionTimeoutFailure to ProfileRequestFailure', () {
+ final result = const ConnectionTimeoutFailure().toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, const ConnectionTimeoutFailure().message);
+ });
+
+ test('maps ServerErrorFailure to ProfileRequestFailure', () {
+ final result = const ServerErrorFailure().toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, const ServerErrorFailure().message);
+ });
+
+ test('maps UnknownNetworkFailure to ProfileRequestFailure', () {
+ final result = const UnknownNetworkFailure().toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, const UnknownNetworkFailure().message);
+ });
+
+ test('maps auth-session and access failures to ProfileRequestFailure', () {
+ const failures = [
+ UnauthorizedFailure(code: 'token_expired'),
+ UnauthorizedFailure(code: 'session_expired_inactivity'),
+ UnauthorizedFailure(code: 'session_expired_absolute'),
+ UnauthorizedFailure(),
+ ForbiddenFailure(),
+ ];
+
+ for (final failure in failures) {
+ final result = failure.toProfileFailure();
+
+ expect(result, isA());
+ expect(result.message, failure.message);
+ }
+ });
+ });
+}
diff --git a/test/features/profile/data/repositories/profile_repository_impl_test.dart b/test/features/profile/data/repositories/profile_repository_impl_test.dart
new file mode 100644
index 00000000..21fbfa69
--- /dev/null
+++ b/test/features/profile/data/repositories/profile_repository_impl_test.dart
@@ -0,0 +1,326 @@
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart';
+import 'package:moveup_flutter/core/utils/logger/app_logger.dart';
+import 'package:moveup_flutter/features/auth/domain/entities/user.dart';
+import 'package:moveup_flutter/features/profile/data/dto/change_password_request_dto.dart';
+import 'package:moveup_flutter/features/profile/data/dto/update_profile_request_dto.dart';
+import 'package:moveup_flutter/features/profile/data/remote/profile_api_client.dart';
+import 'package:moveup_flutter/features/profile/data/repositories/profile_repository_impl.dart';
+import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart';
+
+import '../../support/profile_dto_fixtures.dart';
+import 'profile_repository_impl_test.mocks.dart';
+
+@GenerateNiceMocks([
+ MockSpec(),
+ MockSpec(),
+])
+void main() {
+ late MockAppLogger logger;
+ late MockProfileApiClient apiClient;
+ late ProfileRepository repository;
+
+ setUp(() {
+ logger = MockAppLogger();
+ apiClient = MockProfileApiClient();
+ repository = ProfileRepositoryImpl(logger, apiClient);
+ });
+
+ group('ProfileRepositoryImpl', () {
+ group('getUser', () {
+ test('returns success(user) when api succeeds', () async {
+ // Arrange
+ final responseDto = createProfileUserResponseDto();
+ when(apiClient.getProfile()).thenAnswer((_) async => responseDto);
+
+ // Act
+ final result = await repository.getUser();
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ expect(result.success, createProfileUser());
+
+ verify(apiClient.getProfile()).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('returns ProfileRequestFailure when api returns server error', () async {
+ // Arrange
+ final exception = createProfileDioBadResponseException(
+ path: '/api/profile',
+ statusCode: 500,
+ code: 'server_error',
+ );
+ when(apiClient.getProfile()).thenThrow(exception);
+
+ // Act
+ final result = await repository.getUser();
+
+ // Assert
+ expect(result.isFailure, isTrue);
+ expect(result.failure, isA());
+ expect(result.failure!.parentException, exception);
+
+ verify(apiClient.getProfile()).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('returns UnknownProfileFailure when unexpected exception occurs', () async {
+ // Arrange
+ final exception = Exception('unexpected_error');
+ when(apiClient.getProfile()).thenThrow(exception);
+
+ // Act
+ final result = await repository.getUser();
+
+ // Assert
+ expect(result.isFailure, isTrue);
+ expect(result.failure, isA());
+ expect(result.failure!.parentException, exception);
+
+ verify(apiClient.getProfile()).called(1);
+ verify(logger.e(any, exception, any)).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+ });
+
+ group('updateUser', () {
+ test('returns currentUser without network calls when nothing changed', () async {
+ // Arrange
+ const currentUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ avatar: testProfileUserAvatar,
+ );
+
+ // Act
+ final result = await repository.updateUser(
+ currentUser: currentUser,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ );
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ expect(result.success, currentUser);
+ verifyNever(apiClient.getProfile());
+ verifyNever(apiClient.updateProfile(any));
+ verifyNever(apiClient.uploadAvatar(any));
+ verifyNever(apiClient.changePassword(any));
+ });
+
+ test('updates name and email only', () async {
+ // Arrange
+ const currentUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ avatar: testProfileUserAvatar,
+ );
+ final refreshedUser = createProfileUserDto(
+ name: 'test_name',
+ email: 'test@mail.com',
+ );
+ when(apiClient.updateProfile(any)).thenAnswer((_) async {});
+ when(
+ apiClient.getProfile(),
+ ).thenAnswer((_) async => createProfileUserResponseDto(user: refreshedUser));
+
+ // Act
+ final result = await repository.updateUser(
+ currentUser: currentUser,
+ name: 'test_name',
+ email: 'test@mail.com',
+ );
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ expect(
+ result.success,
+ createProfileUser(
+ name: 'test_name',
+ email: 'test@mail.com',
+ ),
+ );
+
+ final captured =
+ verify(apiClient.updateProfile(captureAny)).captured.single as UpdateProfileRequestDto;
+ expect(captured.name, 'test_name');
+ expect(captured.email, 'test@mail.com');
+ verifyNever(apiClient.uploadAvatar(any));
+ verify(apiClient.getProfile()).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('updates avatar only', () async {
+ // Arrange
+ const currentUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ );
+ final tempDirectory = await Directory.systemTemp.createTemp('test');
+ final avatarFile = File('${tempDirectory.path}/avatar.jpg');
+ await avatarFile.writeAsString('avatar');
+ when(apiClient.uploadAvatar(any)).thenAnswer((_) async {});
+ when(apiClient.getProfile()).thenAnswer(
+ (_) async => createProfileUserResponseDto(
+ user: createProfileUserDto(avatarUrl: 'new-avatar.jpg'),
+ ),
+ );
+
+ addTearDown(() async {
+ await tempDirectory.delete(recursive: true);
+ });
+
+ // Act
+ final result = await repository.updateUser(
+ currentUser: currentUser,
+ name: currentUser.name,
+ email: currentUser.email,
+ avatarPath: avatarFile.path,
+ );
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ expect(result.success!.avatar, 'new-avatar.jpg');
+
+ verify(apiClient.uploadAvatar(any)).called(1);
+ verifyNever(apiClient.updateProfile(any));
+ verify(apiClient.getProfile()).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('updates avatar and text fields in one save flow', () async {
+ // Arrange
+ const currentUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ );
+ final tempDirectory = await Directory.systemTemp.createTemp('profile_repository_test');
+ final avatarFile = File('${tempDirectory.path}/avatar.jpg');
+ await avatarFile.writeAsString('avatar');
+ when(apiClient.uploadAvatar(any)).thenAnswer((_) async {});
+ when(apiClient.updateProfile(any)).thenAnswer((_) async {});
+ when(apiClient.getProfile()).thenAnswer(
+ (_) async => createProfileUserResponseDto(
+ user: createProfileUserDto(
+ name: 'test_name',
+ email: 'test@mail.com',
+ avatarUrl: 'new-avatar.jpg',
+ ),
+ ),
+ );
+
+ addTearDown(() async {
+ await tempDirectory.delete(recursive: true);
+ });
+
+ // Act
+ final result = await repository.updateUser(
+ currentUser: currentUser,
+ name: 'test_name',
+ email: 'test@mail.com',
+ avatarPath: avatarFile.path,
+ );
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ expect(
+ result.success,
+ createProfileUser(
+ name: 'test_name',
+ email: 'test@mail.com',
+ avatar: 'new-avatar.jpg',
+ ),
+ );
+
+ verify(apiClient.uploadAvatar(any)).called(1);
+ verify(apiClient.updateProfile(any)).called(1);
+ verify(apiClient.getProfile()).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+ });
+
+ group('changePassword', () {
+ test('returns success when api succeeds', () async {
+ // Arrange
+ when(apiClient.changePassword(any)).thenAnswer((_) async {});
+
+ // Act
+ final result = await repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ );
+
+ // Assert
+ expect(result.isSuccess, isTrue);
+ final captured =
+ verify(apiClient.changePassword(captureAny)).captured.single
+ as ChangePasswordRequestDto;
+ expect(captured.oldPassword, 'oldPass123');
+ expect(captured.newPassword, 'newPass123');
+ expect(captured.newPasswordConfirmation, 'newPass123');
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('returns ProfileValidationFailure when api returns 422', () async {
+ // Arrange
+ final exception = createProfileDioBadResponseException(
+ path: '/api/profile/change-password',
+ statusCode: 422,
+ code: 'validation_failed',
+ errors: const {
+ 'test': ['message'],
+ },
+ );
+ when(apiClient.changePassword(any)).thenThrow(exception);
+
+ // Act
+ final result = await repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ );
+
+ // Assert
+ expect(result.isFailure, isTrue);
+ expect(result.failure, isA());
+ expect(result.failure!.message, 'message');
+ expect(result.failure!.parentException, exception);
+
+ verify(apiClient.changePassword(any)).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+
+ test('returns UnknownProfileFailure when unexpected exception occurs', () async {
+ // Arrange
+ final exception = Exception('unexpected_error');
+ when(apiClient.changePassword(any)).thenThrow(exception);
+
+ // Act
+ final result = await repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ );
+
+ // Assert
+ expect(result.isFailure, isTrue);
+ expect(result.failure, isA());
+ expect(result.failure!.parentException, exception);
+
+ verify(apiClient.changePassword(any)).called(1);
+ verify(logger.e(any, exception, any)).called(1);
+ verifyNoMoreInteractions(apiClient);
+ });
+ });
+ });
+}
diff --git a/test/features/profile/presentation/cubits/change_password_cubit_test.dart b/test/features/profile/presentation/cubits/change_password_cubit_test.dart
new file mode 100644
index 00000000..4c6e7698
--- /dev/null
+++ b/test/features/profile/presentation/cubits/change_password_cubit_test.dart
@@ -0,0 +1,117 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart';
+import 'package:moveup_flutter/core/result/result.dart';
+import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart';
+import 'package:moveup_flutter/features/profile/presentation/cubits/change_password_cubit.dart';
+
+import 'change_password_cubit_test.mocks.dart';
+
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ late MockProfileRepository repository;
+ late ChangePasswordCubit cubit;
+
+ const failure = ProfileValidationFailure(message: 'test');
+
+ setUp(() {
+ repository = MockProfileRepository();
+ cubit = ChangePasswordCubit(repository);
+ provideDummy>(const Success(null));
+ });
+
+ group('ChangePasswordCubit', () {
+ blocTest(
+ 'emits inProgress and succeed when password change succeeds',
+ setUp: () => when(
+ repository.changePassword(
+ oldPassword: anyNamed('oldPassword'),
+ newPassword: anyNamed('newPassword'),
+ newPasswordConfirmation: anyNamed('newPasswordConfirmation'),
+ ),
+ ).thenAnswer((_) async => const Success(null)),
+ build: () => cubit,
+ act: (cubit) => cubit.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ ),
+ expect: () => const [
+ ChangePasswordState.inProgress(),
+ ChangePasswordState.succeed(),
+ ],
+ verify: (_) => verify(
+ repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ ),
+ ).called(1),
+ );
+
+ blocTest(
+ 'emits failed(failure) when password change fails',
+ setUp: () => when(
+ repository.changePassword(
+ oldPassword: anyNamed('oldPassword'),
+ newPassword: anyNamed('newPassword'),
+ newPasswordConfirmation: anyNamed('newPasswordConfirmation'),
+ ),
+ ).thenAnswer((_) async => const Failure(failure)),
+ build: () => cubit,
+ act: (cubit) => cubit.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ ),
+ expect: () => const [
+ ChangePasswordState.inProgress(),
+ ChangePasswordState.failed(failure),
+ ],
+ verify: (_) => verify(
+ repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ ),
+ ).called(1),
+ );
+
+ blocTest(
+ 'emits inProgress only once when changePassword is called twice',
+ setUp: () => when(
+ repository.changePassword(
+ oldPassword: anyNamed('oldPassword'),
+ newPassword: anyNamed('newPassword'),
+ newPasswordConfirmation: anyNamed('newPasswordConfirmation'),
+ ),
+ ).thenAnswer((_) async => const Success(null)),
+ build: () => cubit,
+ act: (cubit) {
+ cubit.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ );
+ cubit.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ );
+ },
+ expect: () => const [
+ ChangePasswordState.inProgress(),
+ ChangePasswordState.succeed(),
+ ],
+ verify: (_) => verify(
+ repository.changePassword(
+ oldPassword: 'oldPass123',
+ newPassword: 'newPass123',
+ newPasswordConfirmation: 'newPass123',
+ ),
+ ).called(1),
+ );
+ });
+}
diff --git a/test/features/profile/presentation/cubits/profile_user_cubit_test.dart b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart
new file mode 100644
index 00000000..412b29e7
--- /dev/null
+++ b/test/features/profile/presentation/cubits/profile_user_cubit_test.dart
@@ -0,0 +1,111 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart';
+import 'package:moveup_flutter/core/result/result.dart';
+import 'package:moveup_flutter/features/auth/domain/entities/user.dart';
+import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart';
+import 'package:moveup_flutter/features/profile/presentation/cubits/profile_user_cubit.dart';
+
+import '../../support/profile_dto_fixtures.dart';
+import 'profile_user_cubit_test.mocks.dart';
+
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ late MockProfileRepository repository;
+ late ProfileUserCubit cubit;
+
+ const seedUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ avatar: testProfileUserAvatar,
+ );
+ const updatedUser = User(
+ id: testProfileUserId,
+ name: 'test',
+ email: 'test@mail.com',
+ avatar: 'new-avatar.jpg',
+ );
+
+ setUp(() {
+ repository = MockProfileRepository();
+ cubit = ProfileUserCubit(repository, seedUser: seedUser);
+ provideDummy>(const Success(seedUser));
+ });
+
+ group('ProfileUserCubit', () {
+ blocTest(
+ 'emits loading and refreshed user when refresh succeeds',
+ setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)),
+ build: () => cubit,
+ act: (cubit) => cubit.refresh(),
+ expect: () => const [
+ ProfileUserState(
+ isLoading: true,
+ user: seedUser,
+ ),
+ ProfileUserState(
+ user: updatedUser,
+ ),
+ ],
+ verify: (_) => verify(repository.getUser()).called(1),
+ );
+
+ blocTest(
+ 'emits loading only once when refresh is called twice in progress',
+ setUp: () => when(repository.getUser()).thenAnswer((_) async => const Success(updatedUser)),
+ build: () => cubit,
+ act: (cubit) {
+ cubit.refresh();
+ cubit.refresh();
+ },
+ expect: () => const [
+ ProfileUserState(
+ isLoading: true,
+ user: seedUser,
+ ),
+ ProfileUserState(
+ user: updatedUser,
+ ),
+ ],
+ verify: (_) => verify(repository.getUser()).called(1),
+ );
+
+ blocTest(
+ 'keeps seed user and stores failure when refresh fails',
+ setUp: () => when(
+ repository.getUser(),
+ ).thenAnswer((_) async => const Failure(ProfileRequestFailure('error_message'))),
+ build: () => cubit,
+ act: (cubit) => cubit.refresh(),
+ expect: () => const [
+ ProfileUserState(
+ isLoading: true,
+ user: seedUser,
+ ),
+ ProfileUserState(
+ user: seedUser,
+ failure: ProfileRequestFailure('error_message'),
+ ),
+ ],
+ verify: (_) => verify(repository.getUser()).called(1),
+ );
+
+ blocTest(
+ 'replaceUser updates current user and clears failure',
+ build: () => cubit,
+ seed: () => const ProfileUserState(
+ user: seedUser,
+ failure: ProfileRequestFailure('error_message'),
+ ),
+ act: (cubit) => cubit.replaceUser(updatedUser),
+ expect: () => const [
+ ProfileUserState(
+ user: updatedUser,
+ ),
+ ],
+ );
+ });
+}
diff --git a/test/features/profile/presentation/cubits/update_profile_cubit_test.dart b/test/features/profile/presentation/cubits/update_profile_cubit_test.dart
new file mode 100644
index 00000000..2ad43271
--- /dev/null
+++ b/test/features/profile/presentation/cubits/update_profile_cubit_test.dart
@@ -0,0 +1,136 @@
+import 'package:bloc_test/bloc_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:moveup_flutter/core/failures/feature/profile/profile_failure.dart';
+import 'package:moveup_flutter/core/result/result.dart';
+import 'package:moveup_flutter/features/auth/domain/entities/user.dart';
+import 'package:moveup_flutter/features/profile/domain/repositories/profile_repository.dart';
+import 'package:moveup_flutter/features/profile/presentation/cubits/update_profile_cubit.dart';
+
+import '../../support/profile_dto_fixtures.dart';
+import 'update_profile_cubit_test.mocks.dart';
+
+@GenerateNiceMocks([MockSpec()])
+void main() {
+ late MockProfileRepository repository;
+ late UpdateProfileCubit cubit;
+
+ const currentUser = User(
+ id: testProfileUserId,
+ name: testProfileUserName,
+ email: testProfileUserEmail,
+ avatar: testProfileUserAvatar,
+ );
+ const updatedUser = User(
+ id: testProfileUserId,
+ name: 'test',
+ email: 'test@mail.com',
+ avatar: 'new-avatar.jpg',
+ );
+ const failure = ProfileRequestFailure('error_message');
+
+ setUp(() {
+ repository = MockProfileRepository();
+ cubit = UpdateProfileCubit(repository);
+ provideDummy>(const Success(updatedUser));
+ });
+
+ group('UpdateProfileCubit', () {
+ blocTest(
+ 'emits inProgress and succeed(user) when update succeeds',
+ setUp: () => when(
+ repository.updateUser(
+ currentUser: anyNamed('currentUser'),
+ name: anyNamed('name'),
+ email: anyNamed('email'),
+ avatarPath: anyNamed('avatarPath'),
+ ),
+ ).thenAnswer((_) async => const Success(updatedUser)),
+ build: () => cubit,
+ act: (cubit) => cubit.updateProfile(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ avatarPath: '/tmp/avatar.jpg',
+ ),
+ expect: () => const [
+ UpdateProfileState.inProgress(),
+ UpdateProfileState.succeed(updatedUser),
+ ],
+ verify: (_) => verify(
+ repository.updateUser(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ avatarPath: '/tmp/avatar.jpg',
+ ),
+ ).called(1),
+ );
+
+ blocTest(
+ 'emits failed(failure) when update fails',
+ setUp: () => when(
+ repository.updateUser(
+ currentUser: anyNamed('currentUser'),
+ name: anyNamed('name'),
+ email: anyNamed('email'),
+ avatarPath: anyNamed('avatarPath'),
+ ),
+ ).thenAnswer((_) async => const Failure(failure)),
+ build: () => cubit,
+ act: (cubit) => cubit.updateProfile(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ ),
+ expect: () => const [
+ UpdateProfileState.inProgress(),
+ UpdateProfileState.failed(failure),
+ ],
+ verify: (_) => verify(
+ repository.updateUser(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ ),
+ ).called(1),
+ );
+
+ blocTest(
+ 'emits inProgress only once when updateProfile is called twice',
+ setUp: () => when(
+ repository.updateUser(
+ currentUser: anyNamed('currentUser'),
+ name: anyNamed('name'),
+ email: anyNamed('email'),
+ avatarPath: anyNamed('avatarPath'),
+ ),
+ ).thenAnswer((_) async => const Success(updatedUser)),
+ build: () => cubit,
+ act: (cubit) {
+ cubit.updateProfile(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ );
+ cubit.updateProfile(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ );
+ },
+ expect: () => const [
+ UpdateProfileState.inProgress(),
+ UpdateProfileState.succeed(updatedUser),
+ ],
+ verify: (_) => verify(
+ repository.updateUser(
+ currentUser: currentUser,
+ name: 'test',
+ email: 'test@mail.com',
+ ),
+ ).called(1),
+ );
+ });
+}
diff --git a/test/features/profile/support/profile_dto_fixtures.dart b/test/features/profile/support/profile_dto_fixtures.dart
new file mode 100644
index 00000000..b607b5a3
--- /dev/null
+++ b/test/features/profile/support/profile_dto_fixtures.dart
@@ -0,0 +1,78 @@
+import 'package:dio/dio.dart';
+import 'package:moveup_flutter/features/auth/domain/entities/user.dart';
+import 'package:moveup_flutter/features/profile/data/dto/profile_user_data_dto.dart';
+import 'package:moveup_flutter/features/profile/data/dto/profile_user_dto.dart';
+import 'package:moveup_flutter/features/profile/data/dto/profile_user_response_dto.dart';
+
+const testProfileUserId = 1;
+const testProfileUserName = 'name';
+const testProfileUserEmail = 'tests@mail.com';
+const testProfileUserAvatar = 'avatar.jpg';
+const testProfileUserCreatedAt = '2026-01-01T10:00:00.000000Z';
+const testProfileUserEmailVerified = true;
+
+/// Test fixture for a shared authenticated [User].
+User createProfileUser({
+ int id = testProfileUserId,
+ String name = testProfileUserName,
+ String email = testProfileUserEmail,
+ String? avatar = testProfileUserAvatar,
+}) => User(
+ id: id,
+ name: name,
+ email: email,
+ avatar: avatar,
+);
+
+/// Test fixture for [ProfileUserDto].
+ProfileUserDto createProfileUserDto({
+ int id = testProfileUserId,
+ String name = testProfileUserName,
+ String email = testProfileUserEmail,
+ String? avatarUrl = testProfileUserAvatar,
+ String createdAt = testProfileUserCreatedAt,
+ bool emailVerified = testProfileUserEmailVerified,
+}) => ProfileUserDto(
+ id: id,
+ name: name,
+ email: email,
+ avatarUrl: avatarUrl,
+ createdAt: createdAt,
+ emailVerified: emailVerified,
+);
+
+/// Test fixture for [ProfileUserResponseDto].
+ProfileUserResponseDto createProfileUserResponseDto({
+ ProfileUserDto? user,
+}) => ProfileUserResponseDto(
+ data: ProfileUserDataDto(
+ user: user ?? createProfileUserDto(),
+ ),
+);
+
+/// Test fixture for Dio bad response exception.
+DioException createProfileDioBadResponseException({
+ required String path,
+ required int statusCode,
+ required String code,
+ String message = 'error_message',
+ Map>? errors,
+}) {
+ final requestOptions = RequestOptions(path: path);
+ final data = {
+ 'code': code,
+ 'message': message,
+ };
+ if (errors != null) {
+ data['errors'] = errors;
+ }
+ return DioException(
+ requestOptions: requestOptions,
+ type: DioExceptionType.badResponse,
+ response: Response