diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index afe814e0..df87d259 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -33,15 +33,16 @@ class AppConfig { } /// Список глобальных BlocProvider'ов + /// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения List get globalBlocProviders => [ - BlocProvider(create: (context) => sl()), - BlocProvider(create: (context) => sl()..getNotifications()), - BlocProvider(create: (context) => sl()..loadFeedBlogPostList()), - BlocProvider(create: (context) => sl()..getMyProfile()), - BlocProvider(create: (context) => sl()..getChatRoomsWithLoading()), - BlocProvider(create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), BlocProvider(lazy: false, create: (context) => sl()), - BlocProvider(create: (context) => sl()), + BlocProvider(lazy: true, create: (context) => sl()), ]; /// Делегаты локализации diff --git a/lib/core/app_initializer.dart b/lib/core/app_initializer.dart index ea0b8f5d..a4e91210 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:developer'; import 'dart:io' as io; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/env_config.dart'; @@ -37,9 +39,19 @@ class AppInitializer { /// Инициализация Firebase static Future _initializeFirebase() async { - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('Firebase initialization timeout'); + }, + ); if (sl().buildType == AppBuildType.prod) { - await FirebaseMessaging.instance.requestPermission(); + try { + await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5)); + } catch (e) { + // Разрешение не критично, продолжаем работу + log('Failed to request Firebase messaging permission: $e'); + } } } @@ -68,6 +80,10 @@ class AppInitializer { options.tracesSampleRate = 1.0; options.debug = false; }, appRunner: appRunner); + FlutterError.onError = (details) async { + if (kDebugMode) return; + await Sentry.captureException(details.exception, stackTrace: details.stack); + }; } } diff --git a/lib/core/di/external_registrations.dart b/lib/core/di/external_registrations.dart index dcff4441..aecd2707 100644 --- a/lib/core/di/external_registrations.dart +++ b/lib/core/di/external_registrations.dart @@ -20,13 +20,19 @@ class ExternalRegistrations { static void _registerDio() { sl.registerLazySingleton( () => - Dio(BaseOptions(baseUrl: sl().baseUrl, headers: {'accept': 'application/json', 'content-type': 'application/json'})) + Dio(BaseOptions( + baseUrl: sl().baseUrl, + headers: {'accept': 'application/json', 'content-type': 'application/json'}, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + )) ..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()]), ); } /// Регистрация Prefs static void _registerPrefs() { - sl.registerSingletonAsync(()async => Prefs(await SharedPreferences.getInstance())); + sl.registerSingletonAsync(() async => Prefs(await SharedPreferences.getInstance())); } } diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 089d7846..a96aa9d5 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/app_config.dart'; @@ -19,12 +20,25 @@ class FirebaseApi { String? token; if (await _isHuaweiDevice()) { - token = await _getHuaweiPushTokenNative(); + token = await _getHuaweiPushTokenNative().timeout( + const Duration(seconds: 5), + onTimeout: () => null, + ); } else { if ((sl().buildType == AppBuildType.prod)) { - await _firebaseMessaging.requestPermission(); + try { + await _firebaseMessaging.requestPermission().timeout( + const Duration(seconds: 5), + ); + } catch (e) { + // Разрешение не критично, продолжаем + log('Failed to request Firebase messaging permission: $e'); + } } - token = await _firebaseMessaging.getToken(); + token = await _firebaseMessaging.getToken().timeout( + const Duration(seconds: 5), + onTimeout: () => null, + ); } return token; } @@ -39,9 +53,15 @@ class FirebaseApi { 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'token': await getToken(), }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('registerAnonymousPushToken timeout'); + }, ); } catch (e, st) { log(e.toString(), stackTrace: st); + // Не пробрасываем ошибку дальше, так как это не критичная операция } } @@ -57,9 +77,15 @@ class FirebaseApi { 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'token': await getToken(), }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('registerPushToken timeout'); + }, ); } catch (e, st) { log(e.toString(), stackTrace: st); + // Не пробрасываем ошибку дальше, так как это не критичная операция } } } diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index d3ed9b95..2f457269 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/repository_registrations.dart'; import 'package:eatvio_app/core/di/service_registrations.dart'; +import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:get_it/get_it.dart'; @@ -18,6 +19,9 @@ Future init({required AppBuildType buildType}) async { // Регистрация внешних зависимостей (Dio, etc.) ExternalRegistrations.registerAll(); + // Ожидание инициализации асинхронных зависимостей + await sl.getAsync(); + // Регистрация сервисов ServiceRegistrations.registerAll(); diff --git a/lib/core/routing/auth_routes.dart b/lib/core/routing/auth_routes.dart index 2ac62618..772cd2eb 100644 --- a/lib/core/routing/auth_routes.dart +++ b/lib/core/routing/auth_routes.dart @@ -1,5 +1,3 @@ -import 'package:eatvio_app/core/firebase_api.dart'; -import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/features/auth/presentation/pages/auth_screen.dart'; @@ -21,10 +19,8 @@ class AuthRoutes { path: RouteConstants.authScreen, name: 'authScreen', builder: (context, state) => const AuthScreen(), - redirect: (context, state) { - sl().registerAnonymousPushToken(); - return null; - }, + // registerAnonymousPushToken вызывается в AuthCubit.initializeUser() + // и не должен блокировать навигацию routes: [ GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()), GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()), diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index 818f625c..88a8db04 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -23,14 +23,16 @@ class MainNavigationRoutes { return [ ShellRoute( builder: (context, state, child) { - return BlocBuilder( - builder: (BuildContext context, ProfileAvatarState state) { - if (state is ProfileAvatarStateLoaded) { - return BottomNavigationBarWidget(profileModel: state.profile, child: child); - } - return const SizedBox.shrink(); - }, - ); + // Инициализация ProfileAvatarCubit при первом использовании ShellRoute + final profileAvatarCubit = context.read(); + if (profileAvatarCubit.state is ProfileAvatarStateLoading) { + // Вызываем getMyProfile() только если состояние loading (первый запуск) + WidgetsBinding.instance.addPostFrameCallback((_) { + profileAvatarCubit.getMyProfile(); + }); + } + + return BottomNavigationBarWidget(child: child); }, routes: [ GoRoute( diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 28d85d36..9b29ad98 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -56,7 +57,8 @@ class OtherRoutes { builder: (context, state) => const WelcomeScreen(), redirect: (context, state) async { try { - final prefs = sl(); + // Prefs уже инициализирован в locator_service, но на всякий случай проверяем + final prefs = await sl.getAsync(); final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false; if (!welcomeScreenWasShowed) { return null; @@ -74,7 +76,27 @@ class OtherRoutes { builder: (context, state) => const UpdateScreen(), redirect: (context, state) async { try { - final versions = (await sl().get('${sl().baseUrl}/api/app_versions')).data; + // Проверка версии с таймаутом, чтобы не блокировать запуск приложения + final versions = await sl() + .get('${sl().baseUrl}/api/app_versions') + .timeout( + const Duration(seconds: 5), + onTimeout: () { + // При таймауте пропускаем проверку и идем дальше + throw TimeoutException('Version check timeout'); + }, + ) + .then((response) => response.data) + .catchError((e) { + // При любой ошибке пропускаем проверку версии + return null; + }); + + if (versions == null) { + // Если проверка не удалась, пропускаем экран обновления + return RouteConstants.welcomeScreen; + } + PackageInfo packageInfo = await PackageInfo.fromPlatform(); String version = packageInfo.version; Version currentVersion = Version.parse(version); @@ -85,6 +107,7 @@ class OtherRoutes { return RouteConstants.welcomeScreen; } } catch (e) { + // При любой ошибке пропускаем экран обновления return RouteConstants.welcomeScreen; } }, diff --git a/lib/core/token_storage.dart b/lib/core/token_storage.dart index f67e919c..9925f9aa 100644 --- a/lib/core/token_storage.dart +++ b/lib/core/token_storage.dart @@ -1,9 +1,24 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class TokenStorage { + static SharedPreferences? _cachedPrefs; + + static Future _getPrefs() async { + _cachedPrefs ??= await SharedPreferences.getInstance(); + return _cachedPrefs!; + } + + /// Clears the cached SharedPreferences instance. + /// This is primarily used for testing to ensure fresh state between tests. + @visibleForTesting + static void clearCache() { + _cachedPrefs = null; + } + static Future haveTokenInStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); try { + final sharedPreferences = await _getPrefs(); if (sharedPreferences.getString('token') == null) { return false; } else { @@ -15,25 +30,25 @@ abstract class TokenStorage { } static Future getTokenFromStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await _getPrefs(); return sharedPreferences.getString('token'); } static Future saveTokenInStorage(String token) async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await _getPrefs(); await sharedPreferences.setString('token', token); } static Future removeTokenInStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await _getPrefs(); await sharedPreferences.remove('token'); } } abstract class SocketIdStorage { static Future haveSocketIdInStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); try { + final sharedPreferences = await TokenStorage._getPrefs(); if (sharedPreferences.getString('socketId') == null) { return false; } else { @@ -45,17 +60,17 @@ abstract class SocketIdStorage { } static Future getSocketIdFromStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await TokenStorage._getPrefs(); return sharedPreferences.getString('socketId'); } static Future saveSocketIdInStorage(String socketId) async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await TokenStorage._getPrefs(); await sharedPreferences.setString('socketId', socketId); } static Future removeSocketIdInStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); + final sharedPreferences = await TokenStorage._getPrefs(); await sharedPreferences.remove('socketId'); } } diff --git a/lib/core/utils/markdown_utils.dart b/lib/core/utils/markdown_utils.dart new file mode 100644 index 00000000..0cb0c4a2 --- /dev/null +++ b/lib/core/utils/markdown_utils.dart @@ -0,0 +1,67 @@ +/// Утилиты для обработки markdown текста +class MarkdownUtils { + /// Обрабатывает markdown текст: + /// - Исправляет неправильный синтаксис markdown ссылок + /// - Автоматически преобразует URL в markdown ссылки + /// - Преобразует **текст** в текст для надежного отображения жирного текста + static String processMarkdown(String text) { + // Сначала исправляем неправильный синтаксис markdown ссылок + // Например: [http://example.com](url) -> [http://example.com](http://example.com) + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + text = text.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + // Находим все существующие markdown ссылки и их позиции + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(text)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + + // Регулярное выражение для поиска URL (http://, https://, www.) + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + + // Заменяем URL на markdown ссылки, если они еще не являются ссылками + String result = text; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(text)) { + // Проверяем, не находится ли URL внутри существующей markdown ссылки + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + + // Гарантируем, что жирный текст будет распознан + // Заменяем **текст** на текст вручную для надежности + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + result = result.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + + return result; + } +} + +/// Вспомогательный класс для хранения диапазона позиций ссылки +class _LinkRange { + final int start; + final int end; + + _LinkRange(this.start, this.end); +} diff --git a/lib/core/widgets/markdown_html_widget.dart b/lib/core/widgets/markdown_html_widget.dart new file mode 100644 index 00000000..5361e840 --- /dev/null +++ b/lib/core/widgets/markdown_html_widget.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; +import 'package:eatvio_app/core/utils/markdown_utils.dart'; + +/// Переиспользуемый виджет для отображения HTML из markdown текста +class MarkdownHtmlWidget extends StatelessWidget { + /// Markdown текст для отображения + final String markdownText; + + /// Ширина изображений (по умолчанию ширина экрана минус 32) + final double? imageWidth; + + /// Цвет текста для body и параграфов + final Color? textColor; + + /// Размер шрифта (по умолчанию 16) + final double? fontSize; + + /// Callback для обработки нажатий на ссылки + final OnTap? onLinkTap; + + const MarkdownHtmlWidget({ + super.key, + required this.markdownText, + this.imageWidth, + this.textColor, + this.fontSize, + this.onLinkTap, + }); + + @override + Widget build(BuildContext context) { + // Обрабатываем markdown текст + final processedText = MarkdownUtils.processMarkdown(markdownText); + + // Конвертируем markdown в HTML + final htmlData = md.markdownToHtml( + processedText, + extensionSet: md.ExtensionSet.gitHubFlavored, + ); + + final screenWidth = MediaQuery.maybeOf(context)?.size.width ?? 400.0; + final defaultImageWidth = imageWidth ?? (screenWidth - 32); + final defaultTextColor = textColor ?? Colors.black87; + final defaultFontSize = fontSize ?? 16.0; + + return Html( + data: htmlData, + style: { + "body": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + fontSize: FontSize(defaultFontSize), + lineHeight: const LineHeight(1.5), + color: defaultTextColor, + ), + "p": Style( + margin: Margins.only(bottom: 12), + color: defaultTextColor, + ), + "b, strong": Style( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + "h1, h2, h3, h4, h5, h6": Style( + margin: Margins.only(top: 16, bottom: 8), + color: Colors.black, + fontWeight: FontWeight.bold, + ), + "h1": Style(fontSize: FontSize(24)), + "h2": Style(fontSize: FontSize(20)), + "h3": Style(fontSize: FontSize(18)), + "ul, ol": Style( + margin: Margins.only(bottom: 12), + padding: HtmlPaddings.only(left: 16), + ), + "li": Style( + margin: Margins.only(bottom: 4), + color: defaultTextColor, + ), + "strong, b": Style( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + "em, i": Style(fontStyle: FontStyle.italic), + "code": Style( + backgroundColor: Colors.grey.shade100, + padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), + fontFamily: 'monospace', + ), + "pre": Style( + backgroundColor: Colors.grey.shade100, + padding: HtmlPaddings.all(12), + margin: Margins.only(bottom: 12), + ), + "blockquote": Style( + padding: HtmlPaddings.only(left: 16), + margin: Margins.only(left: 8, bottom: 12), + fontStyle: FontStyle.italic, + color: Colors.grey.shade700, + ), + "a": Style( + color: Colors.blue, + textDecoration: TextDecoration.underline, + ), + "img": Style(width: Width(defaultImageWidth)), + }, + onLinkTap: onLinkTap ?? ((url, attributes, element) async { + if (url != null) { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + debugPrint('Error launching URL: $e'); + } + } + }) as OnTap, + ); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart index 4e3c7db9..c80daada 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart @@ -38,18 +38,25 @@ class AuthCubit extends Cubit { } Future initializeUser() async { - await registerAnonymousPushToken(); - if (await TokenStorage.haveTokenInStorage()) { - final profile = await profileRepository.getMyProfile(); - if (profile.emailVerifiedAt != null) { - await registerPushToken(); - AppRouter.navigatorKey.currentContext?.go(RouteConstants.feedScreen); - return; - } - AppRouter.navigatorKey.currentContext?.go(RouteConstants.verifyEmailScreen); - } else { + try { await registerAnonymousPushToken(); - FlutterNativeSplash.remove(); + if (await TokenStorage.haveTokenInStorage()) { + final profile = await profileRepository.getMyProfile(); + if (profile.emailVerifiedAt != null) { + await registerPushToken(); + FlutterNativeSplash.remove(); + AppRouter.navigatorKey.currentContext?.go(RouteConstants.feedScreen); + return; + } + FlutterNativeSplash.remove(); + AppRouter.navigatorKey.currentContext?.go(RouteConstants.verifyEmailScreen); + } else { + FlutterNativeSplash.remove(); + } + } catch (e) { + log('Error in initializeUser: $e'); + FlutterNativeSplash.remove(); // Убираем сплэш даже при ошибке + rethrow; } } diff --git a/lib/features/auth/sdk/social_auth_facade.dart b/lib/features/auth/sdk/social_auth_facade.dart index 4d2e9745..da6bed80 100644 --- a/lib/features/auth/sdk/social_auth_facade.dart +++ b/lib/features/auth/sdk/social_auth_facade.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart'; import 'package:flutter/material.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/env_config.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import 'package:vk_id/vk_id.dart'; @@ -255,8 +256,9 @@ class SocialAuthFacade { if (!context.mounted) return null; return showDialog>( context: context, - barrierDismissible: false, - builder: (_) { + barrierDismissible: true, + builder: (dialogContext) { + final s = S.of(dialogContext); const borderRadius = BorderRadius.all(Radius.circular(16)); return Dialog( insetPadding: const EdgeInsets.all(16), @@ -264,7 +266,21 @@ class SocialAuthFacade { elevation: 0, child: ClipRRect( borderRadius: borderRadius, - child: SizedBox(height: 560, child: WebViewWidget(controller: controller)), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + title: Text(s.authorization), + ), + body: Expanded(child: WebViewWidget(controller: controller)), + ), + ), ), ); }, diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 139f9d10..70593f8c 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; @@ -12,13 +13,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class BlogPostScreen extends StatefulWidget { final int id; @@ -79,98 +78,99 @@ class _BlogPostScreenState extends State { resizeToAvoidBottomInset: true, bottomSheet: Container( padding: EdgeInsets.only( - top: 12, - bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar + bottom: MediaQuery.of(context).padding.bottom + 8, ), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (replyId != null) - GestureDetector( - onTap: () { - context.read().setReplyId(null); - setState(() { - replyId = null; - textEditingController.clear(); - }); - FocusManager.instance.primaryFocus?.unfocus(); - }, - child: Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), - height: 32, - width: 32, - child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), - ), - ), - if (replyId != null) const SizedBox(width: 8), - SizedBox( - width: MediaQuery.of(context).size.width - (replyId == null ? 64 : 104), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 52), - child: TextFormField( - textCapitalization: TextCapitalization.sentences, - onChanged: (_) { - setState(() {}); + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (replyId != null) + GestureDetector( + onTap: () { + context.read().setReplyId(null); + setState(() { + replyId = null; + textEditingController.clear(); + }); + FocusManager.instance.primaryFocus?.unfocus(); }, - controller: textEditingController, - maxLines: 5, - minLines: 1, - keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), - decoration: InputDecoration( - isDense: false, - // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - // вертикальный паддинг увеличен - hintText: 'Комментарий', - hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + child: Container( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + height: 32, + width: 32, + child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), + ), + ), + if (replyId != null) const SizedBox(width: 8), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: TextFormField( + textCapitalization: TextCapitalization.sentences, + onChanged: (_) { + setState(() {}); + }, + controller: textEditingController, + maxLines: 5, + minLines: 1, + keyboardType: TextInputType.multiline, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), + decoration: InputDecoration( + isDense: false, + // убираем true, чтобы padding учитывался корректно + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + // вертикальный паддинг увеличен + hintText: S.of(context).comment_hint, + hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), ), ), ), ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () { - if (textEditingController.text.length > 2) { - commentsCubit.sendComment( - id: widget.id.toString(), - commentText: textEditingController.text, - parentCommentId: replyId, - commentSourceType: CommentSourceType.blogPost, - ); - context.read().setReplyId(null); - setState(() { - textEditingController.clear(); - replyId = null; - }); - FocusManager.instance.primaryFocus?.unfocus(); - } - }, - child: Container( - decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), - height: 32, - width: 32, - child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + if (textEditingController.text.length > 2) { + commentsCubit.sendComment( + id: widget.id.toString(), + commentText: textEditingController.text, + parentCommentId: replyId, + commentSourceType: CommentSourceType.blogPost, + ); + context.read().setReplyId(null); + setState(() { + textEditingController.clear(); + replyId = null; + }); + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Container( + decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), + height: 32, + width: 32, + child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + ), ), - ), - ], + ], + ), ), ), appBar: AppBar( @@ -236,104 +236,8 @@ class _BlogPostScreenState extends State { if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Builder( - builder: (context) { - // Автоматически преобразуем URL в markdown ссылки - String description = blogPostState.blogPostModel!.description; - - // Сначала исправляем неправильный синтаксис markdown ссылок, где URL в тексте, а не в href - // Например: [http://example.com](url) -> [http://example.com](http://example.com) - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - description = description.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - // Если текст ссылки является URL, используем его как href - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; // Оставляем как есть, если это не URL - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - // Используем URL как href, а текст ссылки - это сам URL - return '[$urlInText]($fullUrl)'; - }); - - // Теперь находим все существующие markdown ссылки и их позиции - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - - for (final linkMatch in linkRegex.allMatches(description)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - - // Регулярное выражение для поиска URL (http://, https://, www.) - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - - // Заменяем URL на markdown ссылки, если они еще не являются ссылками - String result = description; - int offset = 0; - - for (final urlMatch in urlRegex.allMatches(description)) { - // Проверяем, не находится ли URL внутри существующей markdown ссылки - bool isInsideLink = existingLinks.any((link) => urlMatch.start >= link.start && urlMatch.end <= link.end); - - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange(urlMatch.start + offset, urlMatch.end + offset, replacement); - offset += replacement.length - url.length; - } - } - - description = result; - - final htmlData = md.markdownToHtml(description.replaceAll('\n', '
'), extensionSet: md.ExtensionSet.commonMark); - debugPrint('Markdown input: ${blogPostState.blogPostModel!.description}'); - debugPrint('Processed markdown: $description'); - debugPrint('HTML output: $htmlData'); - return Html( - data: htmlData, - style: { - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), - "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: Colors.black87), - "strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - debugPrint('Link tapped: $url'); - if (url != null) { - try { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - debugPrint('Cannot launch URL: $url'); - } - } catch (e) { - debugPrint('Error launching URL: $e'); - } - } - }, - ); - }, + child: MarkdownHtmlWidget( + markdownText: blogPostState.blogPostModel!.description, ), ), const SizedBox(height: 8), @@ -436,10 +340,3 @@ class _BlogPostScreenState extends State { ); } } - -class _LinkRange { - final int start; - final int end; - - _LinkRange(this.start, this.end); -} 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 b1e846ca..f177d57a 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 @@ -5,7 +5,8 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart'; -import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -16,13 +17,11 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; class BottomNavigationBarWidget extends StatefulWidget { - final ProfileModel profileModel; final Widget child; const BottomNavigationBarWidget({ super.key, required this.child, - required this.profileModel, }); @override @@ -31,15 +30,12 @@ class BottomNavigationBarWidget extends StatefulWidget { class _BottomNavigationBarWidgetState extends State { int currentBottomNavBarIndex = 1; - late final List pages; + List? pages; int tapsCount = 0; - @override - void initState() { - super.initState(); - - if (widget.profileModel.roles.contains('consultant')) { - pages = [ + List _getPages(bool isConsultant) { + if (isConsultant) { + return [ '/my-profile-screen', '/feed-screen', '/diary-screen', @@ -49,7 +45,7 @@ class _BottomNavigationBarWidgetState extends State { '/menu-screen', ]; } else { - pages = [ + return [ '/my-profile-screen', '/feed-screen', '/diary-screen', @@ -61,7 +57,8 @@ class _BottomNavigationBarWidgetState extends State { } void _onItemTapped(int index) async { - if (pages.length == 7) { + if (pages == null) return; + if (pages!.length == 7) { if (index == 6) { tapsCount++; if (tapsCount == 7) { @@ -82,108 +79,117 @@ class _BottomNavigationBarWidgetState extends State { setState(() { currentBottomNavBarIndex = index; }); - GoRouter.of(context).replace(pages[currentBottomNavBarIndex]); + GoRouter.of(context).replace(pages![currentBottomNavBarIndex]); } } @override Widget build(BuildContext context) { final s = S.of(context); - return Scaffold( - body: widget.child, - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - onTap: _onItemTapped, - selectedFontSize: 10, - unselectedFontSize: 10, - selectedItemColor: appTheme.blueA400, - currentIndex: currentBottomNavBarIndex, - items: [ - BottomNavigationBarItem( - label: '', - icon: CircleAvatar( - foregroundImage: NetworkImage( - widget.profileModel.profilePhotoUrl.contains('https') ? widget.profileModel.profilePhotoUrl : sl().baseUrl + widget.profileModel.profilePhotoUrl, - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - radius: 16, - ), - ), - 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, - ), - ), - ), - ), - 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, - ), - ), - ), - ), - if (widget.profileModel.roles.contains('consultant')) - BottomNavigationBarItem( - label: 'Консультации', - icon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - 'assets/icons/consultations.svg', - width: 20, - height: 20, - ), - ), - activeIcon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - 'assets/icons/consultations.svg', - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, + return BlocBuilder( + builder: (BuildContext context, ProfileAvatarState 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( + type: BottomNavigationBarType.fixed, + onTap: _onItemTapped, + selectedFontSize: 10, + unselectedFontSize: 10, + selectedItemColor: appTheme.blueA400, + currentIndex: currentBottomNavBarIndex, + items: [ + BottomNavigationBarItem( + label: '', + icon: CircleAvatar( + foregroundImage: NetworkImage( + profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl().baseUrl + profileModel.profilePhotoUrl, + ), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + radius: 16, ), ), + 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, ), ), - BottomNavigationBarItem( - label: s.chat, + 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, + ), + ), + ), + ), + if (profileModel.roles.contains('consultant')) + BottomNavigationBarItem( + label: 'Консультации', + icon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + 'assets/icons/consultations.svg', + width: 20, + height: 20, + ), + ), + activeIcon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + 'assets/icons/consultations.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + appTheme.blueA400, + BlendMode.srcIn, + ), + ), + ), + ), + BottomNavigationBarItem( + label: s.chat, icon: BlocBuilder( builder: (BuildContext context, ChatsState state) { final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; @@ -206,7 +212,7 @@ class _BottomNavigationBarWidgetState extends State { ImageConstant.icnChat, width: 20, height: 20, - colorFilter: pages[currentBottomNavBarIndex] == '/chat-select-screen' + colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' ? ColorFilter.mode( appTheme.blueA400, BlendMode.srcIn, @@ -217,9 +223,9 @@ class _BottomNavigationBarWidgetState extends State { ); }, ), - ), - BottomNavigationBarItem( - label: 'Уведомления', + ), + BottomNavigationBarItem( + label: 'Уведомления', icon: BlocBuilder( builder: (BuildContext context, NotificationsState state) { return badges.Badge( @@ -237,7 +243,7 @@ class _BottomNavigationBarWidgetState extends State { padding: const EdgeInsets.only(bottom: 2), child: SvgPicture.asset( ImageConstant.icnBell, - colorFilter: pages[currentBottomNavBarIndex] == '/notifications-screen' + colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' ? ColorFilter.mode( appTheme.blueA400, BlendMode.srcIn, @@ -251,15 +257,22 @@ class _BottomNavigationBarWidgetState extends State { }, ), ), - BottomNavigationBarItem( - label: '', - icon: SvgPicture.asset( - ImageConstant.imgLogo, - height: 32, + BottomNavigationBarItem( + label: '', + icon: SvgPicture.asset( + ImageConstant.imgLogo, + height: 32, + ), + ), + ], ), - ), - ], - ), + ); + } + // Показываем child даже когда профиль еще загружается, чтобы экраны могли инициализироваться + return Scaffold( + body: widget.child, + ); + }, ); } } diff --git a/lib/features/chat/presentation/pages/chats_screen.dart b/lib/features/chat/presentation/pages/chats_screen.dart index befaa00f..539e971c 100644 --- a/lib/features/chat/presentation/pages/chats_screen.dart +++ b/lib/features/chat/presentation/pages/chats_screen.dart @@ -20,6 +20,19 @@ class ChatsScreen extends StatefulWidget { class _ChatsScreenState extends State { final searchController = TextEditingController(); + @override + void initState() { + super.initState(); + // Инициализация ChatsCubit при первом открытии экрана + WidgetsBinding.instance.addPostFrameCallback((_) { + final cubit = context.read(); + final state = cubit.state; + if (state.state == BaseState.initial) { + cubit.getChatRoomsWithLoading(); + } + }); + } + @override Widget build(BuildContext context) { return BlocBuilder( diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index cf40281c..c3d9067d 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -165,6 +165,11 @@ class _AddProductModalState extends State { if (widget.portion != null) const SizedBox(width: 8), InkWell( onTap: () { + // Автоматически выбираем первый элемент, если текст точно совпадает + if (selectedGlossary == null && glossariesList.isNotEmpty && productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { + selectedGlossary = glossariesList[0]; + } + if (widget.portion == null) { widget.diaryCubit.addProductToMeal( diaryId: widget.state.diary.id, diff --git a/lib/features/diary/presentation/widgets/chosen_plan_widget.dart b/lib/features/diary/presentation/widgets/chosen_plan_widget.dart index 30ce656c..d8c51ba9 100644 --- a/lib/features/diary/presentation/widgets/chosen_plan_widget.dart +++ b/lib/features/diary/presentation/widgets/chosen_plan_widget.dart @@ -11,55 +11,53 @@ class ChosenPlanWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder(builder: (context, state) { - if (state is DiaryStateLoaded) { - if (state.diary.typeOfDiary.hasChosenPlan) { - return ChosenPlanWidgetContent( - currentPlanDay: state.chosenPlanCurrentDay ?? 0, - planName: state.chosenPlanName ?? '', - planDuration: state.chosenPlanDuration ?? 0, - ); + return BlocBuilder( + builder: (context, state) { + if (state is DiaryStateLoaded) { + if (state.diary.typeOfDiary.hasChosenPlan) { + return ChosenPlanWidgetContent( + currentPlanDay: state.chosenPlanCurrentDay ?? 0, + planName: state.chosenPlanName ?? '', + planDuration: state.chosenPlanDuration ?? 0, + ); + } } - } - return const SizedBox.shrink(); - }); + return const SizedBox.shrink(); + }, + ); } } class ChosenPlanWidgetContent extends StatelessWidget { - const ChosenPlanWidgetContent({ - super.key, - required this.currentPlanDay, - required this.planName, - required this.planDuration, - }); + const ChosenPlanWidgetContent({super.key, required this.currentPlanDay, required this.planName, required this.planDuration}); final int currentPlanDay; final String planName; final int planDuration; @override Widget build(BuildContext context) { - final size = MediaQuery.sizeOf(context); final s = S.of(context); return Column( children: [ Row( children: [ - SizedBox( - width: size.width * 0.4, - child: Text(planName, style: TextStyles.textLMedium.copyWith(color: appTheme.black)), + Expanded( + child: Text( + planName, + style: TextStyles.textLMedium.copyWith(color: appTheme.black), + softWrap: true, + overflow: TextOverflow.visible, + ), ), - const Spacer(), + const SizedBox(width: 8), Container( decoration: BoxDecoration(color: appTheme.dividerColor, borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), child: Text('${s.day} $currentPlanDay из $planDuration', style: TextStyles.textMSemibold.copyWith(color: appTheme.gray)), - ) + ), ], ), - const SizedBox( - height: 16, - ) + const SizedBox(height: 16), ], ); } diff --git a/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart b/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart index c9359cd9..a48e1fca 100644 --- a/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart +++ b/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart @@ -48,6 +48,19 @@ class _GlossaryModerationRequestModalWidgetState extends State { resizeToAvoidBottomInset: true, bottomSheet: Container( padding: EdgeInsets.only( - top: 12, - bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar + bottom: MediaQuery.of(context).padding.bottom + 8, ), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/diets/presentation/widgets/expandable_text.dart b/lib/features/diets/presentation/widgets/expandable_text.dart index 5832ec86..bb2bb9e7 100644 --- a/lib/features/diets/presentation/widgets/expandable_text.dart +++ b/lib/features/diets/presentation/widgets/expandable_text.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -35,7 +36,9 @@ class _ExpandableTextState extends State { void _checkTextOverflow() { final textRenderObject = _textKey.currentContext?.findRenderObject(); if (textRenderObject is RenderBox) { - final maxLinesHeight = widget.maxLines * TextStyles.textMMediumAuto.fontSize! * 1.2; + final fontSize = TextStyles.textMMediumAuto.fontSize ?? 16; + final lineHeight = fontSize * 1.5; + final maxLinesHeight = widget.maxLines * lineHeight; final actualHeight = textRenderObject.size.height; if (actualHeight > maxLinesHeight && !showExpandButton) { @@ -55,6 +58,7 @@ class _ExpandableTextState extends State { @override Widget build(BuildContext context) { final s = S.of(context); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -65,12 +69,18 @@ class _ExpandableTextState extends State { curve: Curves.easeInOut, child: Stack( children: [ - Text( - widget.text, - key: _textKey, - style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black), - maxLines: isExpanded ? null : widget.maxLines, - overflow: TextOverflow.clip, + ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: isExpanded ? double.infinity : (widget.maxLines * (TextStyles.textMMediumAuto.fontSize ?? 16) * 1.5), + ), + child: MarkdownHtmlWidget( + key: _textKey, + markdownText: widget.text, + fontSize: TextStyles.textMMediumAuto.fontSize ?? 16, + textColor: appTheme.black, + ), + ), ), if (!isExpanded && showExpandButton) Positioned.fill( diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 47447e49..ebf116df 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen. import 'package:eatvio_app/features/feed/presentation/bloc/consultants_cubit/consultants_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; @@ -74,6 +75,16 @@ class _FeedScreenContentState extends State { chipsLabelList = hidePlans ? ['Все', 'Рецепты', 'Диеты', 'Консультанты'] : ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты']; consultantsScrollController.addListener(_onScrollConsultants); _checkRegistrationDate(context); + // Инициализация глобальных кубитов при первом открытии экрана + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final feedCubit = context.read(); + final state = feedCubit.state; + // Загружаем данные, если они еще не загружены + if (state is! FeedStateLoaded) { + feedCubit.loadFeedBlogPostList(); + } + }); super.initState(); } diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index baebe3a5..4192aaff 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -7,17 +7,16 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; class BlogPostWidget extends StatefulWidget { final int index; @@ -125,37 +124,46 @@ class _BlogPostWidgetState extends State { if (state.feedBlogPostList[widget.index].description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Html( - data: md.markdownToHtml( - (state.feedBlogPostList[widget.index].description.length > 200 - ? state.feedBlogPostList[widget.index].description.substring(0, 200).replaceAll('\n', '
') - : state.feedBlogPostList[widget.index].description.replaceAll('\n', '
')), - ), - style: { - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), - "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: Colors.black87), - "strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), + child: Builder( + builder: (context) { + String description = state.feedBlogPostList[widget.index].description; + final isLong = description.length > 200; + + if (isLong) { + // Умная обрезка: обрезаем markdown текст, не ломая синтаксис + int cutPoint = 200; + + // Проверяем, есть ли незакрытые markdown теги + String preview = description.substring(0, cutPoint); + + // Считаем открывающие и закрывающие теги + int boldOpen = (preview.split('**').length - 1) ~/ 2; + int boldClose = preview.split('**').length - 1 - boldOpen * 2; + bool hasUnclosedBold = boldOpen > boldClose; + + // Если есть незакрытый bold, ищем закрытие + if (hasUnclosedBold) { + int searchEnd = (cutPoint + 100).clamp(0, description.length); + int boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + // Если не нашли, обрезаем до последнего пробела + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + // Обрезаем до последнего пробела для красоты + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + description = description.substring(0, cutPoint); + } + + return MarkdownHtmlWidget( + markdownText: description, + ); }, ), ), diff --git a/lib/features/feed/presentation/widgets/diets_widget.dart b/lib/features/feed/presentation/widgets/diets_widget.dart index 095de75c..6de38564 100644 --- a/lib/features/feed/presentation/widgets/diets_widget.dart +++ b/lib/features/feed/presentation/widgets/diets_widget.dart @@ -13,12 +13,10 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class DietsWidget extends StatefulWidget { final Diet diet; @@ -117,86 +115,11 @@ class _DietsWidgetState extends State { String details = widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200)}...' : widget.diet.details; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - details = details.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(details)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = details; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(details)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - details = result; - return Html( - data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14), - lineHeight: const LineHeight(1.5), - ), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, + + return MarkdownHtmlWidget( + markdownText: details, + fontSize: TextStyles.textSMedium.fontSize ?? 14, + textColor: appTheme.black, ); }, ), @@ -267,8 +190,3 @@ class _DietsWidgetState extends State { } } -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 054cee40..9b0a3f3d 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -7,6 +7,7 @@ import 'package:eatvio_app/core/widgets/bookmark_button.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; @@ -15,12 +16,9 @@ import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cub import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class PlanWidget extends StatefulWidget { final PlanModel plan; @@ -117,88 +115,11 @@ class _PlanWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Builder( - builder: (context) { - String details = widget.plan.details.length > 200 - ? '${widget.plan.details.substring(0, 200)}...' - : widget.plan.details; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - details = details.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(details)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = details; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(details)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - details = result; - return Html( - data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, - ); - }, + child: MarkdownHtmlWidget( + markdownText: widget.plan.details.length > 200 + ? '${widget.plan.details.substring(0, 200)}...' + : widget.plan.details, + textColor: appTheme.black, ), ), if (widget.plan.details.length > 200) @@ -312,9 +233,3 @@ class _PlanWidgetState extends State { ); } } - -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/features/feed/presentation/widgets/recipes_widget.dart b/lib/features/feed/presentation/widgets/recipes_widget.dart index 347ab8cd..6d9e800a 100644 --- a/lib/features/feed/presentation/widgets/recipes_widget.dart +++ b/lib/features/feed/presentation/widgets/recipes_widget.dart @@ -16,12 +16,10 @@ import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class RecipesWidget extends StatefulWidget { final int index; @@ -155,88 +153,11 @@ class _RecipesWidgetState extends State { String description = widget.recipe.description.length > 200 ? widget.recipe.description.substring(0, 200) : widget.recipe.description; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - description = description.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - // Находим все существующие markdown ссылки и их позиции - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(description)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = description; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(description)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - description = result; - return Html( - data: md.markdownToHtml( - description.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14), - lineHeight: const LineHeight(1.5), - ), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, + + return MarkdownHtmlWidget( + markdownText: description, + fontSize: TextStyles.textSMedium.fontSize ?? 14, + textColor: appTheme.black, ); }, ), @@ -330,8 +251,3 @@ class _RecipesWidgetState extends State { } } -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index a3b8fced..f2f5892e 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -21,12 +21,10 @@ import 'package:eatvio_app/features/personal_diet_suggestion/presentation/person import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class MyDietWidget extends StatefulWidget { final Diet diet; @@ -86,86 +84,11 @@ class _MyDietWidgetState extends State { String details = widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200)}...' : widget.diet.details; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - details = details.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(details)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = details; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(details)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - details = result; - return Html( - data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14), - lineHeight: const LineHeight(1.5), - ), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, + + return MarkdownHtmlWidget( + markdownText: details, + fontSize: TextStyles.textSMedium.fontSize ?? 14, + textColor: appTheme.black, ); }, ), @@ -323,8 +246,3 @@ class _MyDietWidgetState extends State { } } -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/features/my_plans/presentation/widgets/my_plan_widget.dart b/lib/features/my_plans/presentation/widgets/my_plan_widget.dart index f9ee70d2..6c006f34 100644 --- a/lib/features/my_plans/presentation/widgets/my_plan_widget.dart +++ b/lib/features/my_plans/presentation/widgets/my_plan_widget.dart @@ -14,12 +14,10 @@ import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_state.d import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; // Removed SVG icon usage after switching to text buttons import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:markdown/markdown.dart' as md; -import 'package:url_launcher/url_launcher.dart'; class MyPlanWidget extends StatefulWidget { final PlanModel plan; @@ -78,86 +76,11 @@ class _MyPlanWidgetState extends State { String details = widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - details = details.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(details)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = details; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(details)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - details = result; - return Html( - data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14), - lineHeight: const LineHeight(1.5), - ), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, + + return MarkdownHtmlWidget( + markdownText: details, + fontSize: TextStyles.textSMedium.fontSize ?? 14, + textColor: appTheme.black, ); }, ), @@ -268,8 +191,3 @@ class _MyPlanWidgetState extends State { } } -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart index 58e3c65f..33a8a634 100644 --- a/lib/features/notifications/presentation/notifications_screen.dart +++ b/lib/features/notifications/presentation/notifications_screen.dart @@ -24,6 +24,14 @@ class _NotificationsScreenState extends State { void initState() { super.initState(); _scrollController.addListener(_onScroll); + // Инициализация NotificationsCubit при первом открытии экрана + WidgetsBinding.instance.addPostFrameCallback((_) { + final cubit = context.read(); + final state = cubit.state; + if (state is! NotificationsStateLoaded && state is! NotificationsStateError) { + cubit.getNotifications(); + } + }); } @override @@ -108,7 +116,7 @@ class _NotificationsScreenState extends State { ), ], ) - : const Placeholder(), + : const SizedBox(), ); }, ); diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 1fc6dba2..03e94814 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -153,16 +153,13 @@ class _AboutPlanScreenState extends State { backgroundColor: appTheme.gray100, resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - top: 12, - bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index d0714314..e0e5cd01 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -13,10 +13,9 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:markdown/markdown.dart' as md; import 'package:share_plus/share_plus.dart'; class ProfileBlogPostWidget extends StatefulWidget { @@ -107,37 +106,48 @@ class _ProfileBlogPostWidgetState extends State { if (state.blogPosts[widget.index].description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: Html( - data: md.markdownToHtml( - state.blogPosts[widget.index].description.length > 200 - ? state.blogPosts[widget.index].description.substring(0, 200).replaceAll('\n', '
') - : state.blogPosts[widget.index].description.replaceAll('\n', '
'), - ), - style: { - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), - "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: Colors.black87), - "strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), + child: Builder( + builder: (context) { + String description = state.blogPosts[widget.index].description; + final isLong = description.length > 200; + + if (isLong) { + // Умная обрезка: обрезаем markdown текст, не ломая синтаксис + int cutPoint = 200; + + // Проверяем, есть ли незакрытые markdown теги + String preview = description.substring(0, cutPoint); + + // Считаем открывающие и закрывающие теги + int boldOpen = (preview.split('**').length - 1) ~/ 2; + int boldClose = preview.split('**').length - 1 - boldOpen * 2; + bool hasUnclosedBold = boldOpen > boldClose; + + // Если есть незакрытый bold, ищем закрытие + if (hasUnclosedBold) { + int searchEnd = (cutPoint + 100).clamp(0, description.length); + int boldClosePos = description.indexOf('**', cutPoint); + if (boldClosePos != -1 && boldClosePos < searchEnd) { + cutPoint = boldClosePos + 2; + } else { + // Если не нашли, обрезаем до последнего пробела + cutPoint = description.lastIndexOf(' ', 200); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + } else { + // Обрезаем до последнего пробела для красоты + cutPoint = description.lastIndexOf(' ', cutPoint); + if (cutPoint == -1 || cutPoint < 150) cutPoint = 200; + } + + description = description.substring(0, cutPoint); + } + + return MarkdownHtmlWidget( + markdownText: description, + fontSize: 16, + textColor: Colors.black87, + ); }, ), ), diff --git a/lib/features/recipe/presentation/pages/about_recipe_screen.dart b/lib/features/recipe/presentation/pages/about_recipe_screen.dart index 209cb873..a5448a86 100644 --- a/lib/features/recipe/presentation/pages/about_recipe_screen.dart +++ b/lib/features/recipe/presentation/pages/about_recipe_screen.dart @@ -99,108 +99,102 @@ class _AboutRecipeScreenState extends State { resizeToAvoidBottomInset: true, bottomSheet: Container( padding: EdgeInsets.only( - top: 12, - bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar + bottom: MediaQuery.of(context).padding.bottom + 8, ), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisSize: MainAxisSize.min, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (replyId != null) - GestureDetector( - onTap: () { - context.read().setReplyId(null); - setState(() { - replyId = null; - textEditingController.clear(); - }); - FocusManager.instance.primaryFocus?.unfocus(); - }, - child: Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), - height: 32, - width: 32, - child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), - ), - ), - if (replyId != null) const SizedBox(width: 8), - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 52), - child: TextFormField( - textCapitalization: TextCapitalization.sentences, - onChanged: (_) { - setState(() {}); - }, - controller: textEditingController, - maxLines: 5, - minLines: 1, - keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), - decoration: InputDecoration( - isDense: false, - // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - // вертикальный паддинг увеличен - hintText: 'Комментарий', - hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), - ), - ), - ), - ), + if (replyId != null) + GestureDetector( + onTap: () { + context.read().setReplyId(null); + setState(() { + replyId = null; + textEditingController.clear(); + }); + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: Container( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + height: 32, + width: 32, + child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), ), - const SizedBox(width: 8), - GestureDetector( - onTap: () { - if (textEditingController.text.length > 2) { - commentsCubit.sendComment( - id: widget.recipeSlug, - commentText: textEditingController.text, - parentCommentId: replyId, - commentSourceType: CommentSourceType.recipe, - ); - context.read().setReplyId(null); - setState(() { - textEditingController.clear(); - replyId = null; - }); - Future.delayed(const Duration(milliseconds: 500), () { - recipeCubit.getRecipe(widget.recipeSlug, index: widget.index); - }); - FocusManager.instance.primaryFocus?.unfocus(); - } + ), + if (replyId != null) const SizedBox(width: 8), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: TextFormField( + textCapitalization: TextCapitalization.sentences, + onChanged: (_) { + setState(() {}); }, - child: Container( - decoration: BoxDecoration( - color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, - shape: BoxShape.circle, + controller: textEditingController, + maxLines: 5, + minLines: 1, + keyboardType: TextInputType.multiline, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), + decoration: InputDecoration( + isDense: false, + // убираем true, чтобы padding учитывался корректно + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + // вертикальный паддинг увеличен + hintText: s.comment_hint, + hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), - height: 32, - width: 32, - child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), ), ), - ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + if (textEditingController.text.length > 2) { + commentsCubit.sendComment( + id: widget.recipeSlug, + commentText: textEditingController.text, + parentCommentId: replyId, + commentSourceType: CommentSourceType.recipe, + ); + context.read().setReplyId(null); + setState(() { + textEditingController.clear(); + replyId = null; + }); + Future.delayed(const Duration(milliseconds: 500), () { + recipeCubit.getRecipe(widget.recipeSlug, index: widget.index); + }); + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: Container( + decoration: BoxDecoration( + color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, + shape: BoxShape.circle, + ), + height: 32, + width: 32, + child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + ), ), ], ), diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index 71e2da04..c30aebc1 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -12,12 +12,10 @@ import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/m import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_html/flutter_html.dart'; +import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:markdown/markdown.dart' as md; import 'package:share_plus/share_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; class MyRecipeWidget extends StatefulWidget { final MyRecipeModel recipe; @@ -100,86 +98,11 @@ class _MyRecipeWidgetState extends State { String description = widget.recipe.description.length > 200 ? '${widget.recipe.description.substring(0, 200)}...' : widget.recipe.description; - // Автоматически преобразуем URL в markdown ссылки - // Сначала исправляем неправильный синтаксис markdown ссылок - final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); - description = description.replaceAllMapped(wrongLinkRegex, (match) { - final urlInText = match.group(1)!; - if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { - return match.group(0)!; - } - final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; - return '[$urlInText]($fullUrl)'; - }); - - final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); - final existingLinks = <_LinkRange>[]; - for (final linkMatch in linkRegex.allMatches(description)) { - existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); - } - final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); - String result = description; - int offset = 0; - for (final urlMatch in urlRegex.allMatches(description)) { - bool isInsideLink = existingLinks.any((link) => - urlMatch.start >= link.start && urlMatch.end <= link.end); - if (!isInsideLink) { - final url = urlMatch.group(0)!; - final fullUrl = url.startsWith('www.') ? 'https://$url' : url; - final replacement = '[$url]($fullUrl)'; - result = result.replaceRange( - urlMatch.start + offset, - urlMatch.end + offset, - replacement, - ); - offset += replacement.length - url.length; - } - } - description = result; - return Html( - data: md.markdownToHtml( - description.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, - ), - style: { - "body": Style( - margin: Margins.zero, - padding: HtmlPaddings.zero, - fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14), - lineHeight: const LineHeight(1.5), - ), - "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), - "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold), - "h1": Style(fontSize: FontSize(24)), - "h2": Style(fontSize: FontSize(20)), - "h3": Style(fontSize: FontSize(18)), - "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), - "li": Style(margin: Margins.only(bottom: 4), color: appTheme.black), - "strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black), - "em, i": Style(fontStyle: FontStyle.italic), - "code": Style( - backgroundColor: Colors.grey.shade100, - padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), - fontFamily: 'monospace', - ), - "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), - "blockquote": Style( - padding: HtmlPaddings.only(left: 16), - margin: Margins.only(left: 8, bottom: 12), - fontStyle: FontStyle.italic, - color: Colors.grey.shade700, - ), - "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), - "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), - }, - onLinkTap: (url, attributes, element) async { - if (url != null) { - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, + + return MarkdownHtmlWidget( + markdownText: description, + fontSize: TextStyles.textSMedium.fontSize ?? 14, + textColor: appTheme.black, ); }, ), @@ -270,8 +193,3 @@ class _MyRecipeWidgetState extends State { } } -class _LinkRange { - final int start; - final int end; - _LinkRange(this.start, this.end); -} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 28705dcb..16274f16 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -128,6 +128,7 @@ class MessageLookup extends MessageLookupByLibrary { "Минимум 3 символа", ), "authentificate": MessageLookupByLibrary.simpleMessage("Войти"), + "authorization": MessageLookupByLibrary.simpleMessage("Authorization"), "average_pressure": MessageLookupByLibrary.simpleMessage( "Средние показатели давления:", ), @@ -213,6 +214,7 @@ class MessageLookup extends MessageLookupByLibrary { "commentForMeal": MessageLookupByLibrary.simpleMessage( "Прокомментировать прием пищи", ), + "comment_hint": MessageLookupByLibrary.simpleMessage("Комментарий"), "comments": MessageLookupByLibrary.simpleMessage("Комментарии"), "comments_plural": MessageLookupByLibrary.simpleMessage("комментариев"), "complain": MessageLookupByLibrary.simpleMessage("Пожаловаться"), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 097e1180..11fd6fb1 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -47,6 +47,7 @@ class MessageLookup extends MessageLookupByLibrary { "answer": MessageLookupByLibrary.simpleMessage("Ответить"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"), "authentificate": MessageLookupByLibrary.simpleMessage("Войти"), + "authorization": MessageLookupByLibrary.simpleMessage("Авторизация"), "average_pressure": MessageLookupByLibrary.simpleMessage( "Средние показатели давления:", ), @@ -83,6 +84,7 @@ class MessageLookup extends MessageLookupByLibrary { "city_of_living": MessageLookupByLibrary.simpleMessage("Город проживания"), "comfort": MessageLookupByLibrary.simpleMessage("Комфорт"), "comment": MessageLookupByLibrary.simpleMessage("комментарий"), + "comment_hint": MessageLookupByLibrary.simpleMessage("Комментарий"), "comments": MessageLookupByLibrary.simpleMessage("Комментарии"), "comments_plural": MessageLookupByLibrary.simpleMessage("комментариев"), "complain": MessageLookupByLibrary.simpleMessage("Пожаловаться"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 85a0adf7..4118b48b 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -664,6 +664,16 @@ class S { ); } + /// `Authorization` + String get authorization { + return Intl.message( + 'Authorization', + name: 'authorization', + desc: '', + args: [], + ); + } + /// `Загрузка...` String get loading { return Intl.message('Загрузка...', name: 'loading', desc: '', args: []); @@ -3958,6 +3968,16 @@ class S { String get invite { return Intl.message('Пригласить', name: 'invite', desc: '', args: []); } + + /// `Комментарий` + String get comment_hint { + return Intl.message( + 'Комментарий', + name: 'comment_hint', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2926e786..18c1e305 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -69,6 +69,7 @@ "no_account": "У меня нет аккаунта", "authentificate": "Войти", "user_authorization": "Аутентификация", + "authorization": "Authorization", "loading": "Загрузка...", "weight_chart": "График веса", "circumferences_chart": "График обхватов", @@ -466,6 +467,7 @@ "weAreLookingFor": "Мы собираем самое большое сообщество людей, желающих вести здоровый образ жизни!", "letsInvite": "Давайте пригласим близких в итвио", "eatvioIsAHelthyHabbit": "Итвио — это полезная привычка", - "invite": "Пригласить" + "invite": "Пригласить", + "comment_hint": "Комментарий" } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index e5d6f7c9..d3dd10f5 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -69,6 +69,7 @@ "no_account": "У меня нет аккаунта", "authentificate": "Войти", "user_authorization": "Аутентификация", + "authorization": "Авторизация", "loading": "Загрузка...", "weight_chart": "График веса", "circumferences_chart": "График обхватов", @@ -215,5 +216,6 @@ "from": "от", "user_data": "Данные пользователя", "infoAboutOrder": "Информация о заказе", - "notifications": "Уведомления" + "notifications": "Уведомления", + "comment_hint": "Комментарий" } diff --git a/pubspec.yaml b/pubspec.yaml index dd30e1cc..2be3e4c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.11+92 +version: 1.3.12+93 publish_to: 'none' environment: @@ -83,6 +83,7 @@ dependencies: rxdart: ^0.27.7 gpassword: ^1.0.0 webview_flutter_platform_interface: ^2.14.0 + intl_utils: ^2.8.13 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/core/routing/auth_routes_test.dart b/test/core/routing/auth_routes_test.dart index 6437b14d..41ce977d 100644 --- a/test/core/routing/auth_routes_test.dart +++ b/test/core/routing/auth_routes_test.dart @@ -12,10 +12,7 @@ void main() { test('createRoutes содержит маршрут authScreen', () { final routes = AuthRoutes.createRoutes(); - final authRoute = routes.firstWhere( - (route) => route is GoRoute && route.path == '/auth-screen', - orElse: () => throw Exception('Route not found'), - ); + final authRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/auth-screen', orElse: () => throw Exception('Route not found')); expect(authRoute, isA()); }); @@ -67,10 +64,7 @@ void main() { test('createRoutes содержит маршрут verifyScreen', () { final routes = AuthRoutes.createRoutes(); - final verifyRoute = routes.firstWhere( - (route) => route is GoRoute && route.path == '/verify-screen', - orElse: () => throw Exception('Route not found'), - ); + final verifyRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/verify-screen', orElse: () => throw Exception('Route not found')); expect(verifyRoute, isA()); }); @@ -92,44 +86,25 @@ void main() { expect(registerViaSocialRoute, isA()); }); - test('authScreen имеет redirect функцию', () { - final routes = AuthRoutes.createRoutes(); - final authRoute = routes.first as GoRoute; - expect(authRoute.redirect, isNotNull); - }); - test('forgetPasswordScreen builder обрабатывает extra данные', () { final routes = AuthRoutes.createRoutes(); final authRoute = routes.first as GoRoute; - final forgetPasswordRoute = authRoute.routes.firstWhere( - (route) => route is GoRoute && route.path == 'forget-password-screen', - orElse: () => throw Exception('Route not found'), - ) as GoRoute; + final forgetPasswordRoute = + authRoute.routes.firstWhere((route) => route is GoRoute && route.path == 'forget-password-screen', orElse: () => throw Exception('Route not found')) + as GoRoute; expect(forgetPasswordRoute.builder, isNotNull); }); test('verifyScreen builder обрабатывает extra данные', () { final routes = AuthRoutes.createRoutes(); - final verifyRoute = routes.firstWhere( - (route) => route is GoRoute && route.path == '/verify-screen', - ) as GoRoute; + final verifyRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/verify-screen') as GoRoute; expect(verifyRoute.builder, isNotNull); }); test('registerViaSocialScreen builder обрабатывает extra данные', () { final routes = AuthRoutes.createRoutes(); - final registerViaSocialRoute = routes.firstWhere( - (route) => route is GoRoute && route.path == '/register-via-social-screen', - ) as GoRoute; + final registerViaSocialRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/register-via-social-screen') as GoRoute; expect(registerViaSocialRoute.builder, isNotNull); }); }); } - - - - - - - - diff --git a/test/core/token_storage_test.dart b/test/core/token_storage_test.dart index 403339ce..de8fb3b1 100644 --- a/test/core/token_storage_test.dart +++ b/test/core/token_storage_test.dart @@ -6,6 +6,10 @@ void main() { group('TokenStorage', () { setUp(() async { SharedPreferences.setMockInitialValues({}); + // Clear cached instance to ensure fresh state for each test + TokenStorage.clearCache(); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); }); test('haveTokenInStorage returns false when no token is stored', () async { @@ -66,6 +70,10 @@ void main() { group('SocketIdStorage', () { setUp(() async { SharedPreferences.setMockInitialValues({}); + // Clear cached instance to ensure fresh state for each test + TokenStorage.clearCache(); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); }); test('haveSocketIdInStorage returns false when no socketId is stored', () async { diff --git a/test/core/widgets/marketing_bottom_sheet_test.dart b/test/core/widgets/marketing_bottom_sheet_test.dart index 32e92e7b..a54d3e14 100644 --- a/test/core/widgets/marketing_bottom_sheet_test.dart +++ b/test/core/widgets/marketing_bottom_sheet_test.dart @@ -29,6 +29,7 @@ void main() { const MarketingBottomSheet(), ), ); + await tester.pumpAndSettle(); expect(find.byType(MarketingBottomSheet), findsOneWidget); }); @@ -59,6 +60,7 @@ void main() { ), ); + await tester.pump(); await tester.tap(find.text('Show')); await tester.pumpAndSettle(); diff --git a/test/core/widgets/option_widget_test.dart b/test/core/widgets/option_widget_test.dart index cf8a3cf9..6a70eb22 100644 --- a/test/core/widgets/option_widget_test.dart +++ b/test/core/widgets/option_widget_test.dart @@ -24,6 +24,7 @@ void main() { testWidgets('returns SizedBox.shrink when option is null', (tester) async { await tester.pumpWidget(createTestWidget(const OptionWidget(option: null, duration: 7))); + await tester.pumpAndSettle(); expect(find.byType(OptionWidget), findsOneWidget); expect(find.byType(SizedBox), findsWidgets); @@ -33,6 +34,7 @@ void main() { const option = PacketOptions(items: ['Item 1', 'Item 2'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: false); await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7))); + await tester.pumpAndSettle(); expect(find.byType(OptionWidget), findsOneWidget); expect(find.text('Short'), findsOneWidget); @@ -43,6 +45,7 @@ void main() { const option = PacketOptions(items: ['Item 1'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: true); await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7))); + await tester.pumpAndSettle(); expect(find.byType(OptionWidget), findsOneWidget); }); @@ -51,6 +54,7 @@ void main() { const option = PacketOptions(items: ['Item 1'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: false); await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7, disableDuration: true))); + await tester.pumpAndSettle(); expect(find.byType(OptionWidget), findsOneWidget); }); diff --git a/test/core/widgets/show_error_dialog_test.dart b/test/core/widgets/show_error_dialog_test.dart index d56c7fc0..c1c6c533 100644 --- a/test/core/widgets/show_error_dialog_test.dart +++ b/test/core/widgets/show_error_dialog_test.dart @@ -31,6 +31,7 @@ void main() { ), ); + await tester.pump(); await tester.tap(find.text('Show Error')); await tester.pumpAndSettle(); @@ -68,6 +69,7 @@ void main() { ), ); + await tester.pump(); await tester.tap(find.text('Show Error')); await tester.pumpAndSettle(); @@ -103,6 +105,7 @@ void main() { ), ); + await tester.pump(); await tester.tap(find.text('Show Error')); await tester.pumpAndSettle(); @@ -134,6 +137,7 @@ void main() { ), ); + await tester.pump(); await tester.tap(find.text('Show Error')); await tester.pumpAndSettle(); diff --git a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart index b88cc940..721af4b3 100644 --- a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart +++ b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart @@ -2,6 +2,8 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart'; @@ -20,7 +22,7 @@ import 'package:network_image_mock/network_image_mock.dart'; import 'bottom_navigation_bar_widget_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) void main() { group('BottomNavigationBarWidget smoke', () { setUpAll(() async { @@ -86,6 +88,7 @@ void main() { mockNetworkImagesFor(() async { final notificationsCubit = MockNotificationsCubit(); final chatsCubit = MockChatsCubit(); + final profileAvatarCubit = MockProfileAvatarCubit(); when(notificationsCubit.state).thenReturn(const NotificationsState()); when(notificationsCubit.stream).thenAnswer((_) => const Stream.empty()); @@ -93,16 +96,20 @@ void main() { when(chatsCubit.state).thenReturn(ChatsState()); when(chatsCubit.stream).thenAnswer((_) => const Stream.empty()); when(chatsCubit.close()).thenAnswer((_) async => Future.value()); + when(profileAvatarCubit.state).thenReturn(const ProfileAvatarState.loaded(profile: mockUserProfile)); + when(profileAvatarCubit.stream).thenAnswer((_) => const Stream.empty()); + when(profileAvatarCubit.close()).thenAnswer((_) async => Future.value()); await tester.pumpWidget( MultiBlocProvider( providers: [ BlocProvider.value(value: notificationsCubit), BlocProvider.value(value: chatsCubit), + BlocProvider.value(value: profileAvatarCubit), ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(profileModel: mockUserProfile, child: Text('Test Content')), + body: BottomNavigationBarWidget(child: Text('Test Content')), ), ), ), @@ -117,6 +124,7 @@ void main() { mockNetworkImagesFor(() async { final notificationsCubit = MockNotificationsCubit(); final chatsCubit = MockChatsCubit(); + final profileAvatarCubit = MockProfileAvatarCubit(); when(notificationsCubit.state).thenReturn(const NotificationsState()); when(notificationsCubit.stream).thenAnswer((_) => const Stream.empty()); @@ -124,16 +132,20 @@ void main() { when(chatsCubit.state).thenReturn(ChatsState()); when(chatsCubit.stream).thenAnswer((_) => const Stream.empty()); when(chatsCubit.close()).thenAnswer((_) async => Future.value()); + when(profileAvatarCubit.state).thenReturn(const ProfileAvatarState.loaded(profile: mockConsultantProfile)); + when(profileAvatarCubit.stream).thenAnswer((_) => const Stream.empty()); + when(profileAvatarCubit.close()).thenAnswer((_) async => Future.value()); await tester.pumpWidget( MultiBlocProvider( providers: [ BlocProvider.value(value: notificationsCubit), BlocProvider.value(value: chatsCubit), + BlocProvider.value(value: profileAvatarCubit), ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(profileModel: mockConsultantProfile, child: Text('Test Content')), + body: BottomNavigationBarWidget(child: Text('Test Content')), ), ), ), diff --git a/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart b/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart index dbd269f3..0ef9081a 100644 --- a/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart +++ b/test/features/diary/presentation/widgets/chosen_diet_widget_content_test.dart @@ -34,6 +34,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.byType(ChosenDietWidgetContent), findsOneWidget); expect(find.text('Test Diet'), findsOneWidget); @@ -50,6 +51,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.byType(ChosenDietWidgetContent), findsOneWidget); }); @@ -65,6 +67,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text('My Diet'), findsOneWidget); }); diff --git a/test/features/diary/presentation/widgets/chosen_plan_widget_content_test.dart b/test/features/diary/presentation/widgets/chosen_plan_widget_content_test.dart index d5f8d9cf..9050dcd3 100644 --- a/test/features/diary/presentation/widgets/chosen_plan_widget_content_test.dart +++ b/test/features/diary/presentation/widgets/chosen_plan_widget_content_test.dart @@ -37,6 +37,7 @@ void main() { planName: 'Test Plan', planDuration: 14, )); + await tester.pumpAndSettle(); expect(find.byType(ChosenPlanWidgetContent), findsOneWidget); expect(find.text('Test Plan'), findsOneWidget); @@ -48,6 +49,7 @@ void main() { planName: 'My Plan', planDuration: 7, )); + await tester.pumpAndSettle(); expect(find.byType(ChosenPlanWidgetContent), findsOneWidget); expect(find.textContaining('3'), findsOneWidget); @@ -60,6 +62,7 @@ void main() { planName: 'Long Plan', planDuration: 30, )); + await tester.pumpAndSettle(); expect(find.byType(ChosenPlanWidgetContent), findsOneWidget); expect(find.text('Long Plan'), findsOneWidget); diff --git a/test/features/feed/presentation/widgets/comments_widget_test.dart b/test/features/feed/presentation/widgets/comments_widget_test.dart index 185e0157..48974de6 100644 --- a/test/features/feed/presentation/widgets/comments_widget_test.dart +++ b/test/features/feed/presentation/widgets/comments_widget_test.dart @@ -78,6 +78,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.byType(CommentsWidget), findsOneWidget); }); diff --git a/test/features/registration_v2/presentation/widgets/show_successful_registration_bottom_sheet_test.dart b/test/features/registration_v2/presentation/widgets/show_successful_registration_bottom_sheet_test.dart index 6a9945f4..e55692f5 100644 --- a/test/features/registration_v2/presentation/widgets/show_successful_registration_bottom_sheet_test.dart +++ b/test/features/registration_v2/presentation/widgets/show_successful_registration_bottom_sheet_test.dart @@ -63,6 +63,7 @@ void main() { // Act await tester.pumpWidget(createTestWidget(const SizedBox(), mockCubit)); + await tester.pump(); await tester.tap(find.text('Show Bottom Sheet')); await tester.pumpAndSettle(); @@ -107,6 +108,7 @@ void main() { // Act await tester.pumpWidget(createTestWidgetError(const SizedBox(), mockCubit)); + await tester.pump(); await tester.tap(find.text('Show Bottom Sheet')); await tester.pumpAndSettle(); diff --git a/test/features/settings/presentation/pages/change_password_screen_test.dart b/test/features/settings/presentation/pages/change_password_screen_test.dart index b0ada6ec..fe576e3b 100644 --- a/test/features/settings/presentation/pages/change_password_screen_test.dart +++ b/test/features/settings/presentation/pages/change_password_screen_test.dart @@ -54,6 +54,7 @@ void main() { home: ChangePasswordScreen(settingsEntity: settingsEntity), ), ); + await tester.pumpAndSettle(); expect(find.byType(ChangePasswordScreen), findsOneWidget); expect(find.text('Смена пароля'), findsOneWidget); @@ -80,6 +81,7 @@ void main() { home: ChangePasswordScreen(settingsEntity: settingsEntity), ), ); + await tester.pumpAndSettle(); expect(find.byType(AppBar), findsOneWidget); expect(find.text('Смена пароля'), findsOneWidget);