feat: integrate MarkdownHtmlWidget for improved markdown rendering in blog and diet components

This commit is contained in:
mso7nueh
2026-01-13 12:36:12 +03:00
parent a778257215
commit bf2ba1fbda
12 changed files with 1877 additions and 1807 deletions

View File

@@ -0,0 +1,67 @@
/// Утилиты для обработки markdown текста
class MarkdownUtils {
/// Обрабатывает markdown текст:
/// - Исправляет неправильный синтаксис markdown ссылок
/// - Автоматически преобразует URL в markdown ссылки
/// - Преобразует **текст** в <b>текст</b> для надежного отображения жирного текста
static String processMarkdown(String text) {
// Сначала исправляем неправильный синтаксис markdown ссылок
// Например: [http://example.com](url) -> [http://example.com](http://example.com)
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
text = text.replaceAllMapped(wrongLinkRegex, (match) {
final urlInText = match.group(1)!;
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
return match.group(0)!;
}
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
return '[$urlInText]($fullUrl)';
});
// Находим все существующие markdown ссылки и их позиции
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
final existingLinks = <_LinkRange>[];
for (final linkMatch in linkRegex.allMatches(text)) {
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
}
// Регулярное выражение для поиска URL (http://, https://, www.)
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
// Заменяем URL на markdown ссылки, если они еще не являются ссылками
String result = text;
int offset = 0;
for (final urlMatch in urlRegex.allMatches(text)) {
// Проверяем, не находится ли URL внутри существующей markdown ссылки
bool isInsideLink = existingLinks.any((link) =>
urlMatch.start >= link.start && urlMatch.end <= link.end);
if (!isInsideLink) {
final url = urlMatch.group(0)!;
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
final replacement = '[$url]($fullUrl)';
result = result.replaceRange(
urlMatch.start + offset,
urlMatch.end + offset,
replacement,
);
offset += replacement.length - url.length;
}
}
// Гарантируем, что жирный текст будет распознан
// Заменяем **текст** на <b>текст</b> вручную для надежности
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
result = result.replaceAllMapped(boldRegex, (match) {
return '<b>${match.group(1)}</b>';
});
return result;
}
}
/// Вспомогательный класс для хранения диапазона позиций ссылки
class _LinkRange {
final int start;
final int end;
_LinkRange(this.start, this.end);
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:url_launcher/url_launcher.dart';
import 'package:eatvio_app/core/utils/markdown_utils.dart';
/// Переиспользуемый виджет для отображения HTML из markdown текста
class MarkdownHtmlWidget extends StatelessWidget {
/// Markdown текст для отображения
final String markdownText;
/// Ширина изображений (по умолчанию ширина экрана минус 32)
final double? imageWidth;
/// Цвет текста для body и параграфов
final Color? textColor;
/// Размер шрифта (по умолчанию 16)
final double? fontSize;
/// Callback для обработки нажатий на ссылки
final OnTap? onLinkTap;
const MarkdownHtmlWidget({
super.key,
required this.markdownText,
this.imageWidth,
this.textColor,
this.fontSize,
this.onLinkTap,
});
@override
Widget build(BuildContext context) {
// Обрабатываем markdown текст
final processedText = MarkdownUtils.processMarkdown(markdownText);
// Конвертируем markdown в HTML
final htmlData = md.markdownToHtml(
processedText,
extensionSet: md.ExtensionSet.gitHubFlavored,
);
final screenWidth = MediaQuery.of(context).size.width;
final defaultImageWidth = imageWidth ?? (screenWidth - 32);
final defaultTextColor = textColor ?? Colors.black87;
final defaultFontSize = fontSize ?? 16.0;
return Html(
data: htmlData,
style: {
"body": Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(defaultFontSize),
lineHeight: const LineHeight(1.5),
color: defaultTextColor,
),
"p": Style(
margin: Margins.only(bottom: 12),
color: defaultTextColor,
),
"b, strong": Style(
fontWeight: FontWeight.bold,
color: Colors.black,
),
"h1, h2, h3, h4, h5, h6": Style(
margin: Margins.only(top: 16, bottom: 8),
color: Colors.black,
fontWeight: FontWeight.bold,
),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(
margin: Margins.only(bottom: 12),
padding: HtmlPaddings.only(left: 16),
),
"li": Style(
margin: Margins.only(bottom: 4),
color: defaultTextColor,
),
"strong, b": Style(
fontWeight: FontWeight.bold,
color: Colors.black,
),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.all(12),
margin: Margins.only(bottom: 12),
),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(
color: Colors.blue,
textDecoration: TextDecoration.underline,
),
"img": Style(width: Width(defaultImageWidth)),
},
onLinkTap: onLinkTap ?? ((url, attributes, element) async {
if (url != null) {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
debugPrint('Error launching URL: $e');
}
}
}) as OnTap,
);
}
}