diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart index daf156a8..a066c959 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart @@ -36,9 +36,29 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { final emailErrors = errors?['email'] as List?; final phoneErrors = errors?['phone'] as List?; - if ((emailErrors != null && emailErrors.isNotEmpty) || (phoneErrors != null && phoneErrors.isNotEmpty)) { - // Это ошибка валидации - нужно показать экран подтверждения - emit(const OAuthState.error(errorMessage: 'validation_error')); + final errorsList = []; + if (emailErrors != null && emailErrors.isNotEmpty) { + for (final error in emailErrors) { + final errorStr = error.toString(); + if (errorStr.contains('Некорректный формат email')) { + errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); + } + if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { + errorsList.add(RegistrationValidationErrors.emailExist); + } + } + } + if (phoneErrors != null && phoneErrors.isNotEmpty) { + for (final error in phoneErrors) { + final errorStr = error.toString(); + if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { + errorsList.add(RegistrationValidationErrors.phoneExist); + } + } + } + + if (errorsList.isNotEmpty) { + emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList)); } else { emit(OAuthState.error(errorMessage: e.toString())); } @@ -126,7 +146,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { try { emit(const OAuthState.loading()); log('OAuthCubit: Requesting verification code for email: $email, phone: $phone'); - final response = await authRepository.requestOAuthVerification(email: null, phone: phone); + final response = await authRepository.requestOAuthVerification(email: email, phone: phone); if (response != null) { log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}'); } @@ -188,4 +208,3 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { } } -enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat } diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart index 7da9ab7c..517222de 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart @@ -10,5 +10,10 @@ abstract class OAuthState with _$OAuthState { const factory OAuthState.loaded({required String token}) = OAuthStateLoaded; - const factory OAuthState.error({required String errorMessage}) = OAuthStateError; + const factory OAuthState.error({ + required String errorMessage, + List? registrationValidationErrors, + }) = OAuthStateError; } + +enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat } diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index f177d57a..efac96bf 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -1,5 +1,6 @@ import 'package:badges/badges.dart' as badges; import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; @@ -19,10 +20,7 @@ import 'package:go_router/go_router.dart'; class BottomNavigationBarWidget extends StatefulWidget { final Widget child; - const BottomNavigationBarWidget({ - super.key, - required this.child, - }); + const BottomNavigationBarWidget({super.key, required this.child}); @override State createState() => _BottomNavigationBarWidgetState(); @@ -35,24 +33,9 @@ class _BottomNavigationBarWidgetState extends State { List _getPages(bool isConsultant) { if (isConsultant) { - return [ - '/my-profile-screen', - '/feed-screen', - '/diary-screen', - '/consultations-screen', - '/chat-select-screen', - '/notifications-screen', - '/menu-screen', - ]; + return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; } else { - return [ - '/my-profile-screen', - '/feed-screen', - '/diary-screen', - '/chat-select-screen', - '/notifications-screen', - '/menu-screen', - ]; + return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; } } @@ -83,6 +66,49 @@ class _BottomNavigationBarWidgetState extends State { } } + static const Color _devAccentColor = Color(0xFFF59E0B); // amber-500 + + Widget _buildLogoIcon() { + final isProduction = sl().baseUrl == EnvConfig.baseUrl; + final logo = SvgPicture.asset( + ImageConstant.imgLogo, + height: 32, + colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn), + ); + if (isProduction) return logo; + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5), + color: _devAccentColor.withValues(alpha: 0.08), + ), + child: logo, + ), + Positioned( + top: -6, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: _devAccentColor, + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))], + ), + child: const Text( + 'DEV', + style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0), + ), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -91,10 +117,10 @@ class _BottomNavigationBarWidgetState extends State { if (state is ProfileAvatarStateLoaded) { final profileModel = state.profile; final isConsultant = profileModel.roles.contains('consultant'); - + // Инициализируем pages на основе profileModel pages ??= _getPages(isConsultant); - + return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( @@ -117,52 +143,26 @@ class _BottomNavigationBarWidgetState extends State { ), BottomNavigationBarItem( label: 'Лента', - icon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - width: 20, - height: 20, - ImageConstant.icnFeed, - ), - ), - activeIcon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnFeed, - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ), - ), - ), + icon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed), + ), + activeIcon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), + ), ), BottomNavigationBarItem( label: s.diary, - icon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ImageConstant.icnDiary, - width: 20, - height: 20, - ), - ), - activeIcon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnDiary, - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, + icon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20), + ), + activeIcon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), + ), ), - ), - ), - ), if (profileModel.roles.contains('consultant')) BottomNavigationBarItem( label: 'Консультации', @@ -181,97 +181,69 @@ class _BottomNavigationBarWidgetState extends State { 'assets/icons/consultations.svg', width: 20, height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ), - ), - ), - ), + colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + ), + ), + ), BottomNavigationBarItem( label: s.chat, - icon: BlocBuilder( - builder: (BuildContext context, ChatsState state) { - final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; - return badges.Badge( - position: badges.BadgePosition.topEnd(), - badgeAnimation: const badges.BadgeAnimation.fade(), - badgeStyle: badges.BadgeStyle( - badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent, + icon: BlocBuilder( + builder: (BuildContext context, ChatsState state) { + final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; + return badges.Badge( + position: badges.BadgePosition.topEnd(), + badgeAnimation: const badges.BadgeAnimation.fade(), + badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent), + badgeContent: Text( + hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '', + style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + ImageConstant.icnChat, + width: 20, + height: 20, + colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' + ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) + : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ), + ), + ); + }, ), - badgeContent: Text( - hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '', - style: TextStyle( - color: hasUnreadMessages ? Colors.white : Colors.transparent, - fontSize: 8, - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnChat, - width: 20, - height: 20, - colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' - ? ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ) - : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ), - ), - ); - }, - ), ), BottomNavigationBarItem( label: 'Уведомления', - icon: BlocBuilder( - builder: (BuildContext context, NotificationsState state) { - return badges.Badge( - position: badges.BadgePosition.topEnd(), - badgeAnimation: const badges.BadgeAnimation.fade(), - showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications, - badgeContent: const Text( - '', - style: TextStyle( - color: Colors.white, - fontSize: 8, - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnBell, - colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' - ? ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ) - : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - width: 20, - height: 20, - ), - ), - ); - }, - ), - ), - BottomNavigationBarItem( - label: '', - icon: SvgPicture.asset( - ImageConstant.imgLogo, - height: 32, + icon: BlocBuilder( + builder: (BuildContext context, NotificationsState state) { + return badges.Badge( + position: badges.BadgePosition.topEnd(), + badgeAnimation: const badges.BadgeAnimation.fade(), + showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications, + badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)), + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + ImageConstant.icnBell, + colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' + ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) + : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + width: 20, + height: 20, + ), + ), + ); + }, ), ), + BottomNavigationBarItem(label: '', icon: _buildLogoIcon()), ], ), ); } // Показываем child даже когда профиль еще загружается, чтобы экраны могли инициализироваться - return Scaffold( - body: widget.child, - ); + return Scaffold(body: widget.child); }, ); } diff --git a/lib/features/create_diet/presentation/create_diet_screen.dart b/lib/features/create_diet/presentation/create_diet_screen.dart index b77ac3b9..2497b633 100644 --- a/lib/features/create_diet/presentation/create_diet_screen.dart +++ b/lib/features/create_diet/presentation/create_diet_screen.dart @@ -58,6 +58,39 @@ class _CreateDietScreenContentState extends State { final GlobalKey _mdKey = GlobalKey(); bool _mdToolbarVisible = false; + final GlobalKey _nameKey = GlobalKey(); + final GlobalKey _durationKey = GlobalKey(); + final GlobalKey _statusKey = GlobalKey(); + final GlobalKey _typeKey = GlobalKey(); + final GlobalKey _imageKey = GlobalKey(); + final GlobalKey _planKey = GlobalKey(); + + void _scrollToFirstError(CreateDietState state) { + GlobalKey? key; + if (state.dietNameError != null) { + key = _nameKey; + } else if (state.dietDescriptionError != null) { + key = _mdKey; + } else if (state.dietDurationError != null) { + key = _durationKey; + } else if (state.dietStateError != null) { + key = _statusKey; + } else if (state.dietImageError != null) { + key = _imageKey; + } else if (state.dailyScheduleErrors.any((e) => e.isNotEmpty)) { + key = _planKey; + } + + if (key != null && key.currentContext != null) { + Scrollable.ensureVisible( + key.currentContext!, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + alignment: 0.1, + ); + } + } + @override void initState() { super.initState(); @@ -118,6 +151,7 @@ class _CreateDietScreenContentState extends State { children: [ const SizedBox(height: 16), CustomTextFormField( + key: _nameKey, hintText: s.enter_diet_name, controller: nameController, hasError: state.dietNameError != null, @@ -143,19 +177,20 @@ class _CreateDietScreenContentState extends State { ), if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')], const SizedBox(height: 12), - const DietDurationDropDownWidget(), + DietDurationDropDownWidget(key: _durationKey), const SizedBox(height: 12), - StatusDropDownWidget(dietState: widget.diet?.state), + StatusDropDownWidget(key: _statusKey, dietState: widget.diet?.state), const SizedBox(height: 16), - const DietTypeChoiceWidget(), + DietTypeChoiceWidget(key: _typeKey), const SizedBox(height: 16), - const DietImagePickerWidget(), + DietImagePickerWidget(key: _imageKey), const SizedBox(height: 16), ], ), ), const SizedBox(height: 16), MainBackgroundBouble( + key: _planKey, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Center( @@ -185,7 +220,11 @@ class _CreateDietScreenContentState extends State { ? () {} : () async { final diet = await context.read().getDietForPreview(); - if (diet == null) return; + if (diet == null) { + // ignore: use_build_context_synchronously + _scrollToFirstError(context.read().state); + return; + } if (!context.mounted) return; GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet); }, @@ -204,7 +243,10 @@ class _CreateDietScreenContentState extends State { CustomOutlinedButton( onPressed: () async { final result = await context.read().createDiet(); - if (result == 'error') { + if (result == 'validation') { + // ignore: use_build_context_synchronously + _scrollToFirstError(context.read().state); + } else if (result == 'error') { if (!context.mounted) return; showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); } diff --git a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart index 8ecf89ba..f8eb5a73 100644 --- a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart +++ b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart @@ -394,11 +394,11 @@ class CreateDietCubit extends Cubit { result = false; emit(state.copyWith(dietStateError: 'Статус диеты не выбран')); } - if (state.dietData.image == null) { + if (state.dietData.image == null && state.dietData.imageUrl == null) { result = false; emit(state.copyWith(dietImageError: 'Не добавлена обложка')); } - result = _isDailyScheduleValid(); + result = _isDailyScheduleValid() && result; return result; } diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index c3d9067d..a0895bf9 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -132,7 +132,12 @@ class _AddProductModalState extends State { subtitle: glossariesList[i].grInPortion == 0 ? null : Text('${glossariesList[i].grInPortion} г', style: TextStyle(height: 1, color: Colors.grey.shade600)), - trailing: Icon(Icons.add_rounded, color: Colors.grey.shade400), + trailing: Icon( + productNameController.text.trim().toLowerCase() == glossariesList[i].name.toLowerCase() + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: Colors.blueAccent, + ), contentPadding: const EdgeInsets.only(left: 12), ); }, @@ -166,7 +171,9 @@ class _AddProductModalState extends State { InkWell( onTap: () { // Автоматически выбираем первый элемент, если текст точно совпадает - if (selectedGlossary == null && glossariesList.isNotEmpty && productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { + if (selectedGlossary == null && + glossariesList.isNotEmpty && + productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { selectedGlossary = glossariesList[0]; } diff --git a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart index 347ae773..8bf682e2 100644 --- a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart @@ -287,8 +287,7 @@ void main() { const verificationResponse = OAuthVerificationRequestResponse(email: 't***@example.com', status: 'sent'); - // OAuthCubit всегда передает email: null в репозиторий, независимо от переданного параметра - when(mockAuthRepository.requestOAuthVerification(email: null, phone: null)).thenAnswer((_) async => verificationResponse); + when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenAnswer((_) async => verificationResponse); final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com');