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: