From a778257215ab1ff822224f01ef400904be00c64b Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Mon, 12 Jan 2026 16:31:22 +0300 Subject: [PATCH 1/4] chore: enhance markdown processing and UI adjustments in comments input --- .../presentation/pages/blog_post_screen.dart | 181 +++++++++--------- .../presentation/pages/about_diet_screen.dart | 5 +- .../presentation/widgets/expandable_text.dart | 121 +++++++++++- .../widgets/blog_post_widget.dart | 110 ++++++++--- .../presentation/widgets/diets_widget.dart | 12 +- .../presentation/widgets/plan_widget.dart | 12 +- .../presentation/widgets/recipes_widget.dart | 12 +- .../presentation/widgets/my_diet_widget.dart | 12 +- .../presentation/widgets/my_plan_widget.dart | 12 +- .../presentation/pages/about_plan_screen.dart | 7 +- .../widgets/profile_blog_post_widget.dart | 110 ++++++++--- .../pages/about_recipe_screen.dart | 174 ++++++++--------- .../widgets/my_recipe_widget.dart | 12 +- pubspec.yaml | 2 +- 14 files changed, 516 insertions(+), 266 deletions(-) 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..193fa9e2 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -79,98 +79,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: 'Комментарий', + 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( @@ -285,15 +286,23 @@ class _BlogPostScreenState extends State { 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'); + // Гарантируем, что жирный текст будет распознан + // Заменяем **текст** на текст вручную для надежности + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + description = description.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + + final htmlData = md.markdownToHtml( + description, + extensionSet: md.ExtensionSet.gitHubFlavored, + ); return Html( data: htmlData, style: { - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), + "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5), color: Colors.black87), "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), + "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)), diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 82ab3144..54d74215 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -106,15 +106,14 @@ class _AboutDietScreenState 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..cf1c98ce 100644 --- a/lib/features/diets/presentation/widgets/expandable_text.dart +++ b/lib/features/diets/presentation/widgets/expandable_text.dart @@ -5,6 +5,9 @@ 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_svg/svg.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class ExpandableText extends StatefulWidget { final String text; @@ -35,7 +38,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) { @@ -52,9 +57,61 @@ class _ExpandableTextState extends State { }); } + String _processMarkdown(String text) { + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + 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)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(text)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = text; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(text)) { + 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; + } + @override Widget build(BuildContext context) { final s = S.of(context); + final processedText = _processMarkdown(widget.text); + final htmlData = md.markdownToHtml( + processedText, + extensionSet: md.ExtensionSet.gitHubFlavored, + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -65,12 +122,56 @@ 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: Html( + key: _textKey, + data: htmlData, + style: { + "body": Style( + margin: Margins.zero, + padding: HtmlPaddings.zero, + fontSize: FontSize(TextStyles.textMMediumAuto.fontSize ?? 16), + lineHeight: const LineHeight(1.5), + ), + "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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); + } + } + }, + ), + ), ), if (!isExpanded && showExpandButton) Positioned.fill( @@ -121,3 +222,9 @@ class _ExpandableTextState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index baebe3a5..a7334b3e 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -125,37 +125,85 @@ 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); + } + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + description = description.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + + // Конвертируем markdown в HTML + String htmlData = md.markdownToHtml( + description, + extensionSet: md.ExtensionSet.gitHubFlavored, + ); + + return Html( + data: htmlData, + style: { + "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5), color: Colors.black87), + "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), + "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: 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)), + }, + ); }, ), ), diff --git a/lib/features/feed/presentation/widgets/diets_widget.dart b/lib/features/feed/presentation/widgets/diets_widget.dart index 095de75c..4b3428e6 100644 --- a/lib/features/feed/presentation/widgets/diets_widget.dart +++ b/lib/features/feed/presentation/widgets/diets_widget.dart @@ -153,10 +153,17 @@ class _DietsWidgetState extends State { } } details = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + details = details.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + details, + extensionSet: md.ExtensionSet.gitHubFlavored, ), style: { "body": Style( @@ -166,6 +173,7 @@ class _DietsWidgetState extends State { lineHeight: const LineHeight(1.5), ), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index 054cee40..8f5a4bc2 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -158,14 +158,22 @@ class _PlanWidgetState extends State { } } details = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + details = details.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + details, + extensionSet: md.ExtensionSet.gitHubFlavored, ), 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), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), diff --git a/lib/features/feed/presentation/widgets/recipes_widget.dart b/lib/features/feed/presentation/widgets/recipes_widget.dart index 347ab8cd..ed89b183 100644 --- a/lib/features/feed/presentation/widgets/recipes_widget.dart +++ b/lib/features/feed/presentation/widgets/recipes_widget.dart @@ -193,10 +193,17 @@ class _RecipesWidgetState extends State { } } description = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + description = description.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - description.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + description, + extensionSet: md.ExtensionSet.gitHubFlavored, ), style: { "body": Style( @@ -206,6 +213,7 @@ class _RecipesWidgetState extends State { lineHeight: const LineHeight(1.5), ), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), 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..f655b8b9 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -122,10 +122,17 @@ class _MyDietWidgetState extends State { } } details = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + details = details.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + details, + extensionSet: md.ExtensionSet.gitHubFlavored, ), style: { "body": Style( @@ -135,6 +142,7 @@ class _MyDietWidgetState extends State { lineHeight: const LineHeight(1.5), ), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), 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..5a53d76c 100644 --- a/lib/features/my_plans/presentation/widgets/my_plan_widget.dart +++ b/lib/features/my_plans/presentation/widgets/my_plan_widget.dart @@ -114,10 +114,17 @@ class _MyPlanWidgetState extends State { } } details = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + details = details.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - details.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + details, + extensionSet: md.ExtensionSet.gitHubFlavored, ), style: { "body": Style( @@ -127,6 +134,7 @@ class _MyPlanWidgetState extends State { lineHeight: const LineHeight(1.5), ), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), 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..43f05f43 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -107,37 +107,85 @@ 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); + } + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + description = description.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + + // Конвертируем markdown в HTML + String htmlData = md.markdownToHtml( + description, + extensionSet: md.ExtensionSet.gitHubFlavored, + ); + + return Html( + data: htmlData, + style: { + "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5), color: Colors.black87), + "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), + "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: 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)), + }, + ); }, ), ), diff --git a/lib/features/recipe/presentation/pages/about_recipe_screen.dart b/lib/features/recipe/presentation/pages/about_recipe_screen.dart index 209cb873..b74a4201 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: 'Комментарий', + 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..e253756d 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -136,10 +136,17 @@ class _MyRecipeWidgetState extends State { } } description = result; + + // Гарантируем, что жирный текст будет распознан + final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); + description = description.replaceAllMapped(boldRegex, (match) { + return '${match.group(1)}'; + }); + return Html( data: md.markdownToHtml( - description.replaceAll('\n', '
'), - extensionSet: md.ExtensionSet.commonMark, + description, + extensionSet: md.ExtensionSet.gitHubFlavored, ), style: { "body": Style( @@ -149,6 +156,7 @@ class _MyRecipeWidgetState extends State { lineHeight: const LineHeight(1.5), ), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), + "b, strong": Style(fontWeight: FontWeight.bold, 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)), diff --git a/pubspec.yaml b/pubspec.yaml index dd30e1cc..35a43397 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: From d206e7ec12409ed082899131d7189d2b2c0786a6 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Tue, 13 Jan 2026 12:22:22 +0300 Subject: [PATCH 2/4] refactor: implement lazy initialization for global BlocProviders and enhance Firebase initialization with timeout handling --- lib/core/app_config.dart | 15 ++++----- lib/core/app_initializer.dart | 16 ++++++++-- lib/core/di/external_registrations.dart | 10 ++++-- lib/core/firebase_api.dart | 31 +++++++++++++++++-- lib/core/locator_service.dart | 4 +++ lib/core/routing/auth_routes.dart | 8 ++--- lib/core/routing/main_navigation_routes.dart | 9 ++++++ lib/core/routing/other_routes.dart | 27 ++++++++++++++-- lib/core/token_storage.dart | 23 +++++++++----- .../bloc/auth_cubit/auth_cubit.dart | 29 ++++++++++------- .../chat/presentation/pages/chats_screen.dart | 13 ++++++++ .../feed/presentation/pages/feed_screen.dart | 9 ++++++ .../presentation/notifications_screen.dart | 8 +++++ 13 files changed, 161 insertions(+), 41 deletions(-) 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..193aa7e3 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' as io; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/env_config.dart'; @@ -37,9 +38,20 @@ 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) { + // Разрешение не критично, продолжаем работу + } } } 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..887b7137 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,24 @@ 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) { + // Разрешение не критично, продолжаем + } } - token = await _firebaseMessaging.getToken(); + token = await _firebaseMessaging.getToken().timeout( + const Duration(seconds: 5), + onTimeout: () => null, + ); } return token; } @@ -39,9 +52,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 +76,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..d6a4978e 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -23,6 +23,15 @@ class MainNavigationRoutes { return [ ShellRoute( builder: (context, state, child) { + // Инициализация ProfileAvatarCubit при первом использовании ShellRoute + final profileAvatarCubit = context.read(); + if (profileAvatarCubit.state is ProfileAvatarStateLoading) { + // Вызываем getMyProfile() только если состояние loading (первый запуск) + WidgetsBinding.instance.addPostFrameCallback((_) { + profileAvatarCubit.getMyProfile(); + }); + } + return BlocBuilder( builder: (BuildContext context, ProfileAvatarState state) { if (state is ProfileAvatarStateLoaded) { 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..871140c7 100644 --- a/lib/core/token_storage.dart +++ b/lib/core/token_storage.dart @@ -1,9 +1,16 @@ import 'package:shared_preferences/shared_preferences.dart'; abstract class TokenStorage { + static SharedPreferences? _cachedPrefs; + + static Future _getPrefs() async { + _cachedPrefs ??= await SharedPreferences.getInstance(); + return _cachedPrefs!; + } + static Future haveTokenInStorage() async { - final sharedPreferences = await SharedPreferences.getInstance(); try { + final sharedPreferences = await _getPrefs(); if (sharedPreferences.getString('token') == null) { return false; } else { @@ -15,25 +22,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 +52,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/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/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/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 47447e49..0c9dca9d 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,14 @@ class _FeedScreenContentState extends State { chipsLabelList = hidePlans ? ['Все', 'Рецепты', 'Диеты', 'Консультанты'] : ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты']; consultantsScrollController.addListener(_onScrollConsultants); _checkRegistrationDate(context); + // Инициализация глобальных кубитов при первом открытии экрана + WidgetsBinding.instance.addPostFrameCallback((_) { + final feedCubit = context.read(); + final state = feedCubit.state; + if (state is! FeedStateLoaded && state is! FeedStateLoading) { + feedCubit.loadFeedBlogPostList(); + } + }); super.initState(); } diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart index 58e3c65f..afb8e122 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 From 41e96c63c6ab4d90269b2f0cd35ef0dd19e8dba5 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Wed, 14 Jan 2026 13:02:24 +0300 Subject: [PATCH 3/4] feat(token_storage): add clearCache method for testing and ensure fresh state in tests --- lib/core/token_storage.dart | 8 +++++ test/core/routing/auth_routes_test.dart | 39 +++++-------------------- test/core/token_storage_test.dart | 8 +++++ 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/lib/core/token_storage.dart b/lib/core/token_storage.dart index 871140c7..9925f9aa 100644 --- a/lib/core/token_storage.dart +++ b/lib/core/token_storage.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class TokenStorage { @@ -8,6 +9,13 @@ abstract class TokenStorage { 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 { try { final sharedPreferences = await _getPrefs(); 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 { From c0032f7a955f7b451ed68ee4fffdfaab225404c5 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Wed, 14 Jan 2026 16:09:07 +0300 Subject: [PATCH 4/4] feat: enhance Firebase messaging error logging and simplify BottomNavigationBarWidget by removing profileModel dependency --- lib/core/app_initializer.dart | 2 + lib/core/firebase_api.dart | 1 + lib/core/routing/main_navigation_routes.dart | 9 +- .../widgets/bottom_navigation_bar_widget.dart | 247 +++++++++--------- .../feed/presentation/pages/feed_screen.dart | 4 +- .../bottom_navigation_bar_widget_test.dart | 18 +- 6 files changed, 152 insertions(+), 129 deletions(-) diff --git a/lib/core/app_initializer.dart b/lib/core/app_initializer.dart index 193aa7e3..6af9865f 100644 --- a/lib/core/app_initializer.dart +++ b/lib/core/app_initializer.dart @@ -1,4 +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'; @@ -51,6 +52,7 @@ class AppInitializer { ); } catch (e) { // Разрешение не критично, продолжаем работу + log('Failed to request Firebase messaging permission: $e'); } } } diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 887b7137..a96aa9d5 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -32,6 +32,7 @@ class FirebaseApi { ); } catch (e) { // Разрешение не критично, продолжаем + log('Failed to request Firebase messaging permission: $e'); } } token = await _firebaseMessaging.getToken().timeout( diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index d6a4978e..88a8db04 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -32,14 +32,7 @@ class MainNavigationRoutes { }); } - return BlocBuilder( - builder: (BuildContext context, ProfileAvatarState state) { - if (state is ProfileAvatarStateLoaded) { - return BottomNavigationBarWidget(profileModel: state.profile, child: child); - } - return const SizedBox.shrink(); - }, - ); + return BottomNavigationBarWidget(child: child); }, routes: [ GoRoute( 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/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index 0c9dca9d..ebf116df 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -77,9 +77,11 @@ class _FeedScreenContentState extends State { _checkRegistrationDate(context); // Инициализация глобальных кубитов при первом открытии экрана WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; final feedCubit = context.read(); final state = feedCubit.state; - if (state is! FeedStateLoaded && state is! FeedStateLoading) { + // Загружаем данные, если они еще не загружены + if (state is! FeedStateLoaded) { feedCubit.loadFeedBlogPostList(); } }); 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')), ), ), ),