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 f10bb742..08312aeb 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -19,6 +19,7 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class BlogPostScreen extends StatefulWidget { final int id; @@ -233,33 +234,103 @@ class _BlogPostScreenState extends State { if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Html( - data: md.markdownToHtml(blogPostState.blogPostModel!.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) { + // Автоматически преобразуем URL в markdown ссылки + String description = blogPostState.blogPostModel!.description; + + // Сначала исправляем неправильный синтаксис markdown ссылок, где URL в тексте, а не в href + // Например: [http://example.com](url) -> [http://example.com](http://example.com) + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + description = description.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + // Если текст ссылки является URL, используем его как href + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; // Оставляем как есть, если это не URL + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + // Используем URL как href, а текст ссылки - это сам URL + return '[$urlInText]($fullUrl)'; + }); + + // Теперь находим все существующие markdown ссылки и их позиции + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + + for (final linkMatch in linkRegex.allMatches(description)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + + // Регулярное выражение для поиска URL (http://, https://, www.) + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + + // Заменяем URL на markdown ссылки, если они еще не являются ссылками + String result = description; + int offset = 0; + + for (final urlMatch in urlRegex.allMatches(description)) { + // Проверяем, не находится ли URL внутри существующей markdown ссылки + bool isInsideLink = existingLinks.any((link) => urlMatch.start >= link.start && urlMatch.end <= link.end); + + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange(urlMatch.start + offset, urlMatch.end + offset, replacement); + offset += replacement.length - url.length; + } + } + + description = result; + + final htmlData = md.markdownToHtml(description.replaceAll('\n', '
'), extensionSet: md.ExtensionSet.commonMark); + debugPrint('Markdown input: ${blogPostState.blogPostModel!.description}'); + debugPrint('Processed markdown: $description'); + debugPrint('HTML output: $htmlData'); + return Html( + data: htmlData, + style: { + "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), + "p": Style(margin: Margins.only(bottom: 12), color: Colors.black87), + "h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold), + "h1": Style(fontSize: FontSize(24)), + "h2": Style(fontSize: FontSize(20)), + "h3": Style(fontSize: FontSize(18)), + "ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)), + "li": Style(margin: Margins.only(bottom: 4), color: Colors.black87), + "strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black), + "em, i": Style(fontStyle: FontStyle.italic), + "code": Style( + backgroundColor: Colors.grey.shade100, + padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2), + fontFamily: 'monospace', + ), + "pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)), + "blockquote": Style( + padding: HtmlPaddings.only(left: 16), + margin: Margins.only(left: 8, bottom: 12), + fontStyle: FontStyle.italic, + color: Colors.grey.shade700, + ), + "a": Style(color: Colors.blue, textDecoration: TextDecoration.underline), + "img": Style(width: Width(MediaQuery.of(context).size.width - 32)), + }, + onLinkTap: (url, attributes, element) async { + debugPrint('Link tapped: $url'); + if (url != null) { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + debugPrint('Cannot launch URL: $url'); + } + } catch (e) { + debugPrint('Error launching URL: $e'); + } + } + }, + ); }, ), ), @@ -363,3 +434,10 @@ class _BlogPostScreenState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + + _LinkRange(this.start, this.end); +} diff --git a/lib/features/feed/presentation/widgets/diets_widget.dart b/lib/features/feed/presentation/widgets/diets_widget.dart index 257811bf..095de75c 100644 --- a/lib/features/feed/presentation/widgets/diets_widget.dart +++ b/lib/features/feed/presentation/widgets/diets_widget.dart @@ -18,6 +18,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class DietsWidget extends StatefulWidget { final Diet diet; @@ -111,12 +112,52 @@ class _DietsWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.diet.details.length > 200 - ? '${widget.diet.details.substring(0, 200).replaceAll('\n', '
')}...' - : widget.diet.details.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String details = widget.diet.details.length > 200 + ? '${widget.diet.details.substring(0, 200)}...' + : widget.diet.details; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + details = details.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(details)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = details; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(details)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + details = result; + return Html( + data: md.markdownToHtml( + details.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style( margin: Margins.zero, @@ -148,6 +189,16 @@ class _DietsWidgetState extends State { "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); + } + } + }, + ); + }, ), ), ], @@ -215,3 +266,9 @@ class _DietsWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index dae66fbf..054cee40 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -20,6 +20,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class PlanWidget extends StatefulWidget { final PlanModel plan; @@ -116,12 +117,52 @@ class _PlanWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.plan.details.length > 200 - ? '${widget.plan.details.substring(0, 200).replaceAll('\n', '
')}...' - : widget.plan.details.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String details = widget.plan.details.length > 200 + ? '${widget.plan.details.substring(0, 200)}...' + : widget.plan.details; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + details = details.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(details)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = details; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(details)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + details = result; + return Html( + data: md.markdownToHtml( + details.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)), "p": Style(margin: Margins.only(bottom: 12), color: appTheme.black), @@ -148,6 +189,16 @@ class _PlanWidgetState extends State { "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 (widget.plan.details.length > 200) @@ -261,3 +312,9 @@ class _PlanWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/feed/presentation/widgets/recipes_widget.dart b/lib/features/feed/presentation/widgets/recipes_widget.dart index 349fdb7d..347ab8cd 100644 --- a/lib/features/feed/presentation/widgets/recipes_widget.dart +++ b/lib/features/feed/presentation/widgets/recipes_widget.dart @@ -21,6 +21,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class RecipesWidget extends StatefulWidget { final int index; @@ -149,12 +150,54 @@ class _RecipesWidgetState extends State { if (widget.recipe.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.recipe.description.length > 200 - ? widget.recipe.description.substring(0, 200).replaceAll('\n', '
') - : widget.recipe.description.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String description = widget.recipe.description.length > 200 + ? widget.recipe.description.substring(0, 200) + : widget.recipe.description; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + description = description.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + // Находим все существующие markdown ссылки и их позиции + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(description)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = description; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(description)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + description = result; + return Html( + data: md.markdownToHtml( + description.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style( margin: Margins.zero, @@ -186,6 +229,16 @@ class _RecipesWidgetState extends State { "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 (widget.recipe.description.length > 200) @@ -276,3 +329,9 @@ class _RecipesWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index 190f7834..a3b8fced 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -26,6 +26,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class MyDietWidget extends StatefulWidget { final Diet diet; @@ -80,12 +81,52 @@ class _MyDietWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.diet.details.length > 200 - ? '${widget.diet.details.substring(0, 200).replaceAll('\n', '
')}...' - : widget.diet.details.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String details = widget.diet.details.length > 200 + ? '${widget.diet.details.substring(0, 200)}...' + : widget.diet.details; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + details = details.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(details)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = details; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(details)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + details = result; + return Html( + data: md.markdownToHtml( + details.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style( margin: Margins.zero, @@ -117,6 +158,16 @@ class _MyDietWidgetState extends State { "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); + } + } + }, + ); + }, ), ), ], @@ -271,3 +322,9 @@ class _MyDietWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/my_plans/presentation/widgets/my_plan_widget.dart b/lib/features/my_plans/presentation/widgets/my_plan_widget.dart index 14a2567f..f9ee70d2 100644 --- a/lib/features/my_plans/presentation/widgets/my_plan_widget.dart +++ b/lib/features/my_plans/presentation/widgets/my_plan_widget.dart @@ -19,6 +19,7 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:markdown/markdown.dart' as md; +import 'package:url_launcher/url_launcher.dart'; class MyPlanWidget extends StatefulWidget { final PlanModel plan; @@ -72,12 +73,52 @@ class _MyPlanWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.plan.details.length > 200 - ? '${widget.plan.details.substring(0, 200).replaceAll('\n', '
')}...' - : widget.plan.details.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String details = widget.plan.details.length > 200 + ? '${widget.plan.details.substring(0, 200)}...' + : widget.plan.details; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + details = details.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(details)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = details; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(details)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + details = result; + return Html( + data: md.markdownToHtml( + details.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style( margin: Margins.zero, @@ -109,6 +150,16 @@ class _MyPlanWidgetState extends State { "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); + } + } + }, + ); + }, ), ), ], @@ -216,3 +267,9 @@ class _MyPlanWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +} diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index 3e73b7cc..71e2da04 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -17,6 +17,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:markdown/markdown.dart' as md; import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; class MyRecipeWidget extends StatefulWidget { final MyRecipeModel recipe; @@ -94,12 +95,52 @@ class _MyRecipeWidgetState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Html( - data: md.markdownToHtml( - widget.recipe.description.length > 200 - ? '${widget.recipe.description.substring(0, 200).replaceAll('\n', '
')}...' - : widget.recipe.description.replaceAll('\n', '
'), - ), + child: Builder( + builder: (context) { + String description = widget.recipe.description.length > 200 + ? '${widget.recipe.description.substring(0, 200)}...' + : widget.recipe.description; + // Автоматически преобразуем URL в markdown ссылки + // Сначала исправляем неправильный синтаксис markdown ссылок + final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)'); + description = description.replaceAllMapped(wrongLinkRegex, (match) { + final urlInText = match.group(1)!; + if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) { + return match.group(0)!; + } + final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText; + return '[$urlInText]($fullUrl)'; + }); + + final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final existingLinks = <_LinkRange>[]; + for (final linkMatch in linkRegex.allMatches(description)) { + existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end)); + } + final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false); + String result = description; + int offset = 0; + for (final urlMatch in urlRegex.allMatches(description)) { + bool isInsideLink = existingLinks.any((link) => + urlMatch.start >= link.start && urlMatch.end <= link.end); + if (!isInsideLink) { + final url = urlMatch.group(0)!; + final fullUrl = url.startsWith('www.') ? 'https://$url' : url; + final replacement = '[$url]($fullUrl)'; + result = result.replaceRange( + urlMatch.start + offset, + urlMatch.end + offset, + replacement, + ); + offset += replacement.length - url.length; + } + } + description = result; + return Html( + data: md.markdownToHtml( + description.replaceAll('\n', '
'), + extensionSet: md.ExtensionSet.commonMark, + ), style: { "body": Style( margin: Margins.zero, @@ -131,6 +172,16 @@ class _MyRecipeWidgetState extends State { "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); + } + } + }, + ); + }, ), ), ], @@ -218,3 +269,9 @@ class _MyRecipeWidgetState extends State { ); } } + +class _LinkRange { + final int start; + final int end; + _LinkRange(this.start, this.end); +}