chore: enhance markdown processing and UI adjustments in comments input

This commit is contained in:
mso7nueh
2026-01-12 16:31:22 +03:00
parent 497ff19cfa
commit a778257215
14 changed files with 516 additions and 266 deletions

View File

@@ -79,98 +79,99 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
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<CommentsCubit>().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<CommentsCubit>().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<CommentsCubit>().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<CommentsCubit>().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<BlogPostScreen> {
description = result;
final htmlData = md.markdownToHtml(description.replaceAll('\n', '<br>'), extensionSet: md.ExtensionSet.commonMark);
debugPrint('Markdown input: ${blogPostState.blogPostModel!.description}');
debugPrint('Processed markdown: $description');
debugPrint('HTML output: $htmlData');
// Гарантируем, что жирный текст будет распознан
// Заменяем **текст** на <b>текст</b> вручную для надежности
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
description = description.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
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)),

View File

@@ -106,15 +106,14 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
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: [

View File

@@ -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<ExpandableText> {
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<ExpandableText> {
});
}
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 '<b>${match.group(1)}</b>';
});
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<ExpandableText> {
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<ExpandableText> {
);
}
}
class _LinkRange {
final int start;
final int end;
_LinkRange(this.start, this.end);
}

View File

@@ -125,37 +125,85 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
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', '<br>')
: state.feedBlogPostList[widget.index].description.replaceAll('\n', '<br>')),
),
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 '<b>${match.group(1)}</b>';
});
// Конвертируем 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)),
},
);
},
),
),

View File

@@ -153,10 +153,17 @@ class _DietsWidgetState extends State<DietsWidget> {
}
}
details = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
details = details.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
details.replaceAll('\n', '<br>'),
extensionSet: md.ExtensionSet.commonMark,
details,
extensionSet: md.ExtensionSet.gitHubFlavored,
),
style: {
"body": Style(
@@ -166,6 +173,7 @@ class _DietsWidgetState extends State<DietsWidget> {
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)),

View File

@@ -158,14 +158,22 @@ class _PlanWidgetState extends State<PlanWidget> {
}
}
details = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
details = details.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
details.replaceAll('\n', '<br>'),
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)),

View File

@@ -193,10 +193,17 @@ class _RecipesWidgetState extends State<RecipesWidget> {
}
}
description = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
description = description.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
description.replaceAll('\n', '<br>'),
extensionSet: md.ExtensionSet.commonMark,
description,
extensionSet: md.ExtensionSet.gitHubFlavored,
),
style: {
"body": Style(
@@ -206,6 +213,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
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)),

View File

@@ -122,10 +122,17 @@ class _MyDietWidgetState extends State<MyDietWidget> {
}
}
details = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
details = details.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
details.replaceAll('\n', '<br>'),
extensionSet: md.ExtensionSet.commonMark,
details,
extensionSet: md.ExtensionSet.gitHubFlavored,
),
style: {
"body": Style(
@@ -135,6 +142,7 @@ class _MyDietWidgetState extends State<MyDietWidget> {
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)),

View File

@@ -114,10 +114,17 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
}
}
details = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
details = details.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
details.replaceAll('\n', '<br>'),
extensionSet: md.ExtensionSet.commonMark,
details,
extensionSet: md.ExtensionSet.gitHubFlavored,
),
style: {
"body": Style(
@@ -127,6 +134,7 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
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)),

View File

@@ -153,16 +153,13 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
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: [

View File

@@ -107,37 +107,85 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
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', '<br>')
: state.blogPosts[widget.index].description.replaceAll('\n', '<br>'),
),
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 '<b>${match.group(1)}</b>';
});
// Конвертируем 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)),
},
);
},
),
),

View File

@@ -99,108 +99,102 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
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<CommentsCubit>().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<CommentsCubit>().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<CommentsCubit>().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<CommentsCubit>().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),
),
),
],
),

View File

@@ -136,10 +136,17 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
}
}
description = result;
// Гарантируем, что жирный текст будет распознан
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
description = description.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return Html(
data: md.markdownToHtml(
description.replaceAll('\n', '<br>'),
extensionSet: md.ExtensionSet.commonMark,
description,
extensionSet: md.ExtensionSet.gitHubFlavored,
),
style: {
"body": Style(
@@ -149,6 +156,7 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
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)),

View File

@@ -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: